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
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, getActiveCheckout, 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 (getActiveCheckout carries no id — resolve the caller’s cart from the verified context alone):
- 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 — reaches every method on the session field of its 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
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:
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 sendsX-VaultGraph-Idempotency-Key: <uuid> on every mutating method:
createCheckoutaddLineItemsupdateLineItemremoveLineItemapplyDiscountsetFulfillmentDestinationsetFulfillmentsetBuyercompleteCheckoutrequestAuthenticationverifyAuthentication
searchProducts, getProduct, listCategories, getCheckout, getActiveCheckout, 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
TypeScript types for every
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 params column below is the JSON on the wire; if you use the handler, each method receives those fields as one named-argument object (camelCased) alongside context and session. 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. Every field except line_items is optional, and line_items may be empty. context is the shopper’s locality — see Fulfillment:
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. Declare the ones a product expects as its property_specs (see Outputs) so the agent sends the right names and values. Two lines of the same variant with different properties are distinct lines, not a quantity merge:
Buyer — setBuyer. A buyer email is what later unlocks completion:
Buyer carries three name fields — full_name, first_name, last_name — and an agent may send any combination. Store and echo back the ones you were given; deriving one form from the other (splitting full_name on whitespace, joining the parts) mangles names that don’t follow a two-part Western pattern.
Buyer is identity only — email, name, phone. A billing address is a property of a payment method, not of the shopper, so it belongs with payment. For tax, read the checkout’s context or its fulfillment.destination (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, items_discount, fulfillment, tax, fee, total), at most one row per type:
continue_url is the hand-off out of the conversation: a URL that opens this exact cart, already populated, in the shopper’s browser. Point it at your existing checkout or payment page. Agents cannot take a card, so this is how a shopper pays — and how they reach anything else your storefront does that the tools do not cover. Omit it and the checkout is conversation-only.
total is the authoritative amount — the figure the shopper is charged — and the rows above it are the breakdown that explains it, so a discount row is already reflected in total. Roll stacked promotions into one discount row and name them in display_text ("WELCOME10 + Summer Sale"); items_discount is the share of that figure coming from per-line promotions.
A checkout line may carry its own property_specs, in the same shape a Product declares. Return them when what the line can still be customized to depends on what it already carries — a made-to-measure blind whose available drops follow the chosen width, a bike build whose bar options follow the frame. Recompute them on every response and they supersede the product’s declaration for that line; the agent reads them, offers the shopper what is left, and applies the pick with updateLineItem. Omit them and the product’s standing declaration is what the agent goes by.
Nothing in the gateway drives that loop: the specs are relayed untouched, so accepting a partly-configured line and saying what is still outstanding — via status: "incomplete" and a messages entry whose path names the line — is yours to decide.
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.
A product that needs per-line customizations declares them as property_specs. Each entry names the exact key the agent puts in a line’s properties, whether it is required, an optional values list that constrains it to an enum (omit for free text), and an optional description the agent uses to ask the shopper. The gateway passes the declaration to the agent and enforces none of it — addLineItems remains where you validate what arrives, and where you reject a missing or invalid property:
values is an object, not a bare string. value is the exact string the agent sends back. The two optional fields carry what the agent needs to describe the choice before the shopper commits to it:
price_delta— what picking it adds to the line’s unit price, integer minor units of the product’sprice_rangecurrency, negative for a cheaper option. It exists so an agent can answer “how much more for the long strap?” without mutating the cart, which makes it indicative: thetotalsyou return on the checkout are the amount the shopper is charged, and they win wherever the two disagree. Charge what you advertise, or leaveprice_deltaoff.description— anything else about that one choice: added lead time, a heavier parcel, a care requirement. It is free text because nothing computes on it. Keep a price out of it — inprice_deltaan agent can sum it across a multi-option configuration, in prose it cannot.
description says what the property is; a value’s says what that choice means.
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), a destination for anything physical (setFulfillmentDestination), 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.
A condition only counts when you implement the method that satisfies it: skip setFulfillment and a fulfillment method is not required; skip setBuyer and a buyer email is not required. Skip completeCheckout and the checkout never reaches ready_for_complete — leave it incomplete, since no tool can act on the readiness it would advertise.
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.updateLineItemwithpropertiesreplaces that line’s custom fields wholesale; omitting the field leaves them unchanged. Keep the line’s id even when the new properties match another line’s — the agent may be holding that id, so the two lines stay separate rather than merging.
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
Fulfillment is three calls, in order: where it goes, what it costs, which one.setFulfillmentDestination stores a FulfillmentDestination on the checkout at fulfillment.destination. It comes first because a delivery rate cannot be quoted without a locality. Every field on it is optional, so expect it to arrive partial — an agent calls it as soon as the shopper names a country and calls it again with the full address later. Recompute anything locality-dependent here: delivery rates, tax, and status.
listFulfillmentMethods returns the methods selectable for the checkout’s current destination — each a FulfillmentMethod ({ id, name, description, amount, currency, method_type, earliest_fulfillment_date?, latest_fulfillment_date? }), where amount is integer minor units and method_type is shipping, pickup, or digital. Each amount is a committed price the shopper will be charged, so only return methods you can honor for that destination. With no destination set, return what you can offer without one (digital, pickup) rather than guessing a rate.
setFulfillment commits to one of those ids. Reject an id you did not offer for the current destination, and reject a shipping method when no destination is set — conventionally fulfillment_destination_required (400).
There is a second, coarser locality signal: ShopperContext, on CheckoutCreateInput.context and echoed at Checkout.context. It carries address_country, address_region, postal_code, currency, and language — no personal data — so an agent can supply it from a shopper simply saying where they are, before any address exists. Use it for currency, per-market availability, and a tax estimate; use fulfillment.destination for what you actually ship against.
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.