Docs Sellers

Accept middleware

LiveUpdated

View as Markdown

Sellers. Written for merchants that receive agent orders.

@enruta/accept runs in front of your checkout and answers the question no channel answers for you: this order is from which agent, for whom, under which limit and which policy. It changes nothing about your checkout page, your PSP, your acquirer or your merchant of record, and no money passes through it.

Zero runtime dependencies, Web APIs only (WebCrypto, fetch, Request, Response), so the same code runs on Cloudflare Workers, Vercel Edge, Fastly Compute, Deno and Node 22. Apache-2.0.

Install

import { createAccept, toFetchHandler } from "@enruta/accept";

const accept = createAccept({
  domain: "shop.example",
  sellerKey: env.ENRUTA_SELLER_KEY,      // optional: without it the middleware labels but does not report
});

export default { fetch: toFetchHandler(accept, { origin: "https://origin.shop.example" }) };

On Express, when agents sign their requests, mount express.raw({ type: "application/json" }) in front of toExpress instead of express.json(), and parse the JSON in the route: a UCP signature binds the raw body, and express.json() re-serializes it, so the Content-Digest no longer matches.

OptionDefaultMeaning
domainrequiredYour domain, the payee a mandate must name
sellerKeynoneA seller key (enruta_slr_…) from the console; with it, completed orders are reported and you get a record
enrutaApiUrlhttps://api.enruta.ai
platformProfileUrlhttps://app.enruta.ai/.well-known/agent-profile.jsonWhere the mandate keys are fetched (fallback /.well-known/enruta-keys.json)
cacheTtlSeconds3600Key cache
reporttrue when sellerKey is setReport completed orders to Enruta
enforcefalseWhen true, a complete request with an invalid mandate is answered 402 with the AP2 error code; by default it is passed through labelled X-Enruta-Mandate-Valid: false and your own AP2 check decides
webBotAuthonWeb Bot Auth verification: allow and deny origin lists (deny wins), algorithms (ed25519), clockSkewSeconds (60), maxValiditySeconds (86400), requireNonce, requireSignedDirectory, directoryTtlSeconds (3600), directoryMaxTtlSeconds (86400), nonceStore (shared across instances), visaTap (off). false turns verification off and only parses the signature headers
ucpSignatureonUCP signature verification: clockSkewSeconds (60), profileTtlSeconds (3600), profileMaxTtlSeconds (86400), failureTtlSeconds (30), profileTimeoutMs (3000), maxProfileBytes (131072), maxProfileKeys (64), maxBodyBytes (1 MiB). false turns verification off and only parses the signature headers
requireVerifiedBotSignaturefalsetrue: a signed request whose signature verifies under neither Web Bot Auth nor UCP is answered 403 (bot_signature_invalid); "agents": agent traffic without a verified signature is refused too (bot_signature_required). Otherwise a failed signature is only labelled
onEvent, fetchAn event sink for logs; a custom fetch

What it does to each request

  1. Recognizes the agent. a Web Bot Auth signature (RFC 9421 with Signature-Agent), the UCP-Agent header against a fingerprint table (Google, ChatGPT and OpenAI, Copilot, Meta Muse, RyeBot, Enruta, Anthropic, Perplexity, Amazon), known agent User-Agent substrings, and MCP JSON-RPC bodies.
  2. Verifies the agent’s signature. On a signed request it checks both schemes: a Web Bot Auth signature with the key directory of the Signature-Agent origin, taking the key whose thumbprint is keyid (see Web Bot Auth below), and a UCP signature with the key the UCP-Agent profile publishes under keyid (see UCP signatures below). The results are agent.web_bot_auth and agent.ucp_signature, the label X-Enruta-Agent-Verified and, once verified, X-Enruta-Signature-Agent with a web_bot_auth_verified risk signal or X-Enruta-Agent-Profile with a ucp_signature_verified one.
  3. Verifies the mandate. On POST …/checkout-sessions/{id}/complete and on MCP tools/call with name: "complete_checkout", it reads ap2.checkout_mandate, verifies the ES256 signature against Enruta’s published keys, checks payee === domain, exp in the future and checkout.reference === id. The credential token in the body is left for you; it is never logged or forwarded elsewhere.
  4. Labels the request with the headers below, so your own code can write agent_id + mandate_ref + policy_version into your PSP’s metadata, your order attributes or your risk engine.
  5. Reports the order. When you answer 2xx and the body carries an order, it posts { domain, order, mandate_jws, checkout_id, source: "middleware" } to POST /v0/sellers/orders with your seller key, in the background where the runtime allows (ctx.waitUntil), else with a 3 s timeout, failures ignored. Your response gains X-Enruta-Record when the report finished in time.
HeaderValue
X-Enruta-Agentagt_procurement_02
X-Enruta-Mandatemnd_01M2512M8FQ7ZK4T1V9W3N6BXC
X-Enruta-Policy-Version4
X-Enruta-Principalorg_halvern
X-Enruta-Agent-Platformopenai, anthropic, google, copilot, … or none; a hint, since it can come from an unverified header
X-Enruta-Agent-Verifiedtrue or false, on a signed request: whether its Web Bot Auth or UCP signature verified
X-Enruta-Signature-AgentThe verified agent origin, such as https://chatgpt.com, once the Web Bot Auth signature verified
X-Enruta-Agent-ProfileThe UCP-Agent profile URL whose key verified the request’s UCP signature, such as https://chatgpt.com/.well-known/agent-profile.json
X-Enruta-Mandate-Validtrue or false
// accept.inspect(request) → Inspection
{
  agent:   { kind: "ucp" | "web_bot_auth" | "user_agent" | "mcp" | "none", platform: "openai", profile_url: "https://chatgpt.com/.well-known/agent-profile.json",
             signed: true, verified: true, signature_agent: "https://chatgpt.com",
             web_bot_auth: { signed: true, verified: true, status: "verified", protocol: "web_bot_auth", agent_origin: "https://chatgpt.com",
                             directory_url: "https://chatgpt.com/.well-known/http-message-signatures-directory", keyid: "poqk…", tag: "web-bot-auth",
                             alg: "ed25519", label: "sig1", created: 1789048800, expires: 1789048860, nonce: "…",
                             components: ['"@authority"', '"@method"', '"@path"', '"signature-agent";key="sig1"'], directory_signed: true },
             ucp_signature: { signed: true, verified: true, status: "verified", profile_url: "https://chatgpt.com/.well-known/agent-profile.json", keyid: "poqk…", alg: "EdDSA", created: 1789048800, expires: 1789048860 } },
  mandate: { present: true, valid: true, mandate_id: "mnd_01M2512M8FQ7ZK4T1V9W3N6BXC", agent: "agt_procurement_02",
             policy: { id: "pol_office_supplies", version: 4 }, amount: "24.98", currency: "USD", expires_at: "2026-09-10T17:57:08Z",
             principal_login_id: "halvern/dana.reyes@halvernrobotics.com", reasons: [] },
  risk_signals: [{ type: "web_bot_auth_verified", protocol: "web_bot_auth", agent_origin: "https://chatgpt.com", keyid: "poqk…", tag: "web-bot-auth" }]
}

Web Bot Auth

The middleware verifies Web Bot Auth signatures against the current IETF text, draft-ietf-webbotauth-httpsig-protocol-00 (September 1, 2026), which absorbed the key directory draft. A signature counts as verified when all of these hold:

  1. Signature-Input and Signature parse, the signature uses the tag web-bot-auth, and it carries created, expires and keyid.
  2. It covers @authority (or @target-uri) and the Signature-Agent member.
  3. The agent origin named in Signature-Agent is an https origin that passes your allow and deny lists.
  4. created and expires hold within 60 seconds of clock skew, and the window between them is at most 24 hours.
  5. The ed25519 key comes from that origin’s own /.well-known/http-message-signatures-directory (the directory’s own signature is checked when the directory is signed), and the key’s RFC 7638 thumbprint equals keyid.
  6. The RFC 9421 signature verifies, and a nonce, when present, has not been seen before.

The result is agent.web_bot_auth, and agent.verified is its boolean:

web_bot_auth: {
  signed, verified,
  status,                                  // "verified" | "invalid" | "unverified" | "unsigned"
  protocol, agent_origin, directory_url, keyid, tag, alg, label,
  created, expires, nonce, components, directory_signed,
  reason?, detail?                         // why it did not verify
}
statusWhenreason, for example
invalidThe signature, its parameters, its window or its key failinvalid_signature, expired, directory_signature_invalid, nonce_replayed
unverifiedNot enough to decide: no such key, no directory, or an origin that is not allowedunknown_key, directory_unavailable, agent_not_allowed
unsignedThe request carries no signature

A signed request is labelled X-Enruta-Agent-Verified: true or false, and a verified one also X-Enruta-Signature-Agent with the agent origin; the inspection and the order report add the risk signal { type: "web_bot_auth_verified", protocol, agent_origin, keyid, tag }. A signature that verifies under neither Web Bot Auth nor UCP is only labelled unless requireVerifiedBotSignature is set: true answers it with 403, and "agents" also refuses agent traffic without a verified signature. Directories are cached for their max-age (one hour when absent, at most 24 hours), and webBotAuth: false turns verification off.

The Visa Trusted Agent Protocol is supported by the same verifier and off by default (webBotAuth.visaTap): supported, waiting on Visa’s published agent keys. Visa’s key store serves no agent signing keys today, so real Trusted Agent Protocol traffic cannot verify yet.

UCP signatures

The middleware also verifies the RFC 9421 signatures of agents that identify with UCP-Agent: profile="https://…", as the UCP specification (2026-08-25) defines them. It is the scheme of Shopify’s Signed traffic tier, and it is on by default, next to Web Bot Auth. A signature counts as verified when all of these hold:

  1. Signature-Input and Signature parse, the signature is untagged or tagged web-bot-auth (signatures with other tags are left to other verifiers), and it carries keyid.
  2. The profile in UCP-Agent is an https URL on a public host: not an IP literal or a local name, no credentials, at most 1024 characters.
  3. created and expires are optional; when present they hold within 60 seconds of clock skew. UCP leaves replay protection to the signed Idempotency-Key.
  4. It covers @method, @authority and @path; @query when the URL has a query string; content-digest and content-type when the request has a body; and idempotency-key, ucp-agent and signature-agent when the request carries those headers.
  5. Content-Digest has a sha-256 member that matches the raw body bytes.
  6. The profile publishes a key whose kid is keyid, in keys or signing_keys, at the top level or under ucp.
  7. The signature verifies: ES256 as 64 bytes of raw r||s (DER is refused), or Ed25519.

The result is agent.ucp_signature, next to agent.web_bot_auth. agent.verified is true when either verified, and requireVerifiedBotSignature accepts either:

ucp_signature: {
  signed, verified,
  status,                                  // "verified" | "invalid" | "unverified" | "unsigned"
  profile_url, keyid,
  alg,                                     // "ES256" | "EdDSA", from the key
  created, expires,
  reason?, detail?                         // why it did not verify
}
statusWhenreason, for example
invalidThe signature, its parameters, its covered components, its digest or its key failmissing_component, digest_mismatch, expired, unsupported_alg, invalid_signature
unverifiedNot enough to decide: no UCP-Agent profile, a profile that cannot be fetched, no such key, or a body too large to digestucp_agent_missing, profile_unavailable, unknown_key, body_unavailable
unsignedThe request carries no signature

A verified UCP signature is labelled X-Enruta-Agent-Profile with the profile URL, and the inspection and the order report add the risk signal { type: "ucp_signature_verified", profile_url, keyid, alg }. Profiles are cached for their max-age (one hour when absent, at most 24 hours), and an unknown keyid refetches the profile at most once a minute, so rotated keys are picked up. ucpSignature: false turns verification off. Which platforms you trust stays your policy: check it against ucp_signature.profile_url.

What you get back

With a seller key, every mandated order becomes an agent order on your organization’s chain: labels { agent_id, mandate_ref, policy_version, principal, host }, a risk signal { type: "enruta_mandate_verified", score, metadata { enruta_record, mandate, human_approved, amount_within_mandate, payee_in_mandate } } (the middleware also sends web_bot_auth_verified or ucp_signature_verified when the agent’s signature verified), the buyer record’s id and hash, and your own sealed record (kind: agent_order, chain: merchant_view_of). When your store signs its orders, the record’s subject is third_party_signed.

POST /v0/sellers/orders           Authorization: Bearer enruta_slr_…
{ "domain": "reamandrule.com", "order": { "id": "ord_01M2512SM3V3A9R4EBJM8RRRV5", … }, "mandate_jws": "eyJ…", "checkout_id": "chk_01M2511XQ4N7B8T2R6H9K3W5MD", "source": "middleware" }

200 {
  "id": "mor_01M2513C4K8Q2W6T9N1B7D5XRH",
  "labels": { "agent_id": "agt_procurement_02", "mandate_ref": "mnd_01M2512M8FQ7ZK4T1V9W3N6BXC", "policy_version": 4, "principal": "org_halvern", "host": "claude" },
  "risk_signals": [{ "type": "enruta_mandate_verified", "score": 0, "metadata": { "enruta_record": "https://api.enruta.ai/v0/records/rec_01M25129GZMJXTNQ47TMZRDGHN", "mandate": "mnd_01M2512M8FQ7ZK4T1V9W3N6BXC", "human_approved": false, "amount_within_mandate": true, "payee_in_mandate": true } }],
  "seller_record_id": "rec_01M2513CJ7W2T9Q4K8N3B6D1XR",
  "buyer_record_id": "rec_01M25129GZMJXTNQ47TMZRDGHN"
}

Seller keys and domains

A seller organization is created in the console (organization type seller); it adds its domains under Settings, and Enruta verifies each by fetching https://<domain>/.well-known/ucp and storing its keys and endpoints. A seller key may only act for verified domains. Orders can also reach Enruta without the middleware: the UCP order webhook order_placed creates the same agent order when the payee has a seller organization, and the middleware’s report is idempotent per order, so both may run.

RoutePurpose
POST /v0/sellers/ordersReport (observe) an agent order; idempotent per (seller, order_id)
GET /v0/sellers/orders, GET /v0/sellers/orders/{id}Your agent orders with labels, risk signals and record ids; filter order_id=
GET /v0/sellers/orders/{id}/economicsThe money split of an agent order
POST /v0/sellers/verify{ mandate_jws, domain, checkout?, amount? } → { valid, claims, reasons[] }: the same check the middleware makes, server-side
GET /v0/sellers/domains, POST /v0/sellers/domainsList and add domains. A domain is verified when your account proves it controls it (the enruta-domain-verification TXT record or file each domain lists under control, or your store’s ownership proof in Discovery) and its UCP profile is read; one account holds a domain at a time. Adding a domain you have already returns it as it is; verify: true checks it again
GET /v0/sellers/middlewareInstall snippets with your key placeholder, endpoint status, last agent order seen

Labels in your PSP

With a PSP connection, Enruta writes the labels onto the payment in your PSP once the order carries the PSP’s payment reference (psp_payment on POST /v0/sellers/orders; the answer’s psp_write_back says what was written). Stripe, Braintree and PayPal (before capture) accept them after the fact. Adyen, Checkout.com and Worldpay take them only when the payment is created, so Enruta links the payment reference to the order instead, and pspMetadata(psp, labels) from @enruta/accept renders the fields for the code that creates the payment (PSP metadata).

What is not in this version

  • For UCP signatures: replay protection (UCP leaves it to your idempotency store, and the middleware keeps no Idempotency-Key), keys named only in an MCP body’s meta["ucp-agent"] (only the UCP-Agent header’s profile resolves a key), ES384 keys and response signatures.
  • Web Bot Auth keys named through jwks_uri or cimd discovery (only the origin’s own directory binds a key to a domain), delegation and certificate chains (x5c, x5u), and the Visa Trusted Agent Protocol body objects. Without a shared nonceStore, a nonce is checked per instance.

Pricing: Accept is free, permanently: the middleware, verify_record, record intake for mandated orders, the conformance check and badge, and fulfillment confirmation. The reference merchant at reamandrule.com runs exactly this middleware on its complete and /mcp routes.