Docs Sellers
Accept middleware
LiveUpdated
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" }) };// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { createAccept, nextHeadersFor } from "@enruta/accept";
const accept = createAccept({ domain: "shop.example", sellerKey: process.env.ENRUTA_SELLER_KEY });
export async function proxy(request: NextRequest) {
const inspection = await accept.inspect(request);
const headers = new Headers(request.headers);
for (const [k, v] of Object.entries(nextHeadersFor(inspection))) headers.set(k, v);
return NextResponse.next({ request: { headers } });
}
export const config = { matcher: ["/checkout-sessions/:id/complete", "/mcp"] };import { Hono } from "hono";
import { createAccept, toHonoMiddleware } from "@enruta/accept";
const app = new Hono();
app.use("/checkout-sessions/:id/complete", toHonoMiddleware(createAccept({ domain: "shop.example", sellerKey: process.env.ENRUTA_SELLER_KEY })));import express from "express";
import { createAccept, toExpress } from "@enruta/accept";
const app = express();
app.use(toExpress(createAccept({ domain: "shop.example", sellerKey: process.env.ENRUTA_SELLER_KEY })));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.
| Option | Default | Meaning |
|---|---|---|
domain | required | Your domain, the payee a mandate must name |
sellerKey | none | A seller key (enruta_slr_…) from the console; with it, completed orders are reported and you get a record |
enrutaApiUrl | https://api.enruta.ai | |
platformProfileUrl | https://app.enruta.ai/.well-known/agent-profile.json | Where the mandate keys are fetched (fallback /.well-known/enruta-keys.json) |
cacheTtlSeconds | 3600 | Key cache |
report | true when sellerKey is set | Report completed orders to Enruta |
enforce | false | When 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 |
webBotAuth | on | Web 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 |
ucpSignature | on | UCP 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 |
requireVerifiedBotSignature | false | true: 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, fetch | An event sink for logs; a custom fetch |
What it does to each request
- Recognizes the agent. a Web Bot Auth signature (RFC 9421 with
Signature-Agent), theUCP-Agentheader against a fingerprint table (Google, ChatGPT and OpenAI, Copilot, Meta Muse, RyeBot, Enruta, Anthropic, Perplexity, Amazon), known agentUser-Agentsubstrings, and MCP JSON-RPC bodies. - 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-Agentorigin, taking the key whose thumbprint iskeyid(see Web Bot Auth below), and a UCP signature with the key theUCP-Agentprofile publishes underkeyid(see UCP signatures below). The results areagent.web_bot_authandagent.ucp_signature, the labelX-Enruta-Agent-Verifiedand, once verified,X-Enruta-Signature-Agentwith aweb_bot_auth_verifiedrisk signal orX-Enruta-Agent-Profilewith aucp_signature_verifiedone. - Verifies the mandate. On
POST …/checkout-sessions/{id}/completeand on MCPtools/callwithname: "complete_checkout", it readsap2.checkout_mandate, verifies the ES256 signature against Enruta’s published keys, checkspayee === domain,expin the future andcheckout.reference === id. The credential token in the body is left for you; it is never logged or forwarded elsewhere. - Labels the request with the headers below, so your own code can write
agent_id + mandate_ref + policy_versioninto your PSP’s metadata, your order attributes or your risk engine. - Reports the order. When you answer
2xxand the body carries anorder, it posts{ domain, order, mandate_jws, checkout_id, source: "middleware" }toPOST /v0/sellers/orderswith your seller key, in the background where the runtime allows (ctx.waitUntil), else with a 3 s timeout, failures ignored. Your response gainsX-Enruta-Recordwhen the report finished in time.
| Header | Value |
|---|---|
X-Enruta-Agent | agt_procurement_02 |
X-Enruta-Mandate | mnd_01M2512M8FQ7ZK4T1V9W3N6BXC |
X-Enruta-Policy-Version | 4 |
X-Enruta-Principal | org_halvern |
X-Enruta-Agent-Platform | openai, anthropic, google, copilot, … or none; a hint, since it can come from an unverified header |
X-Enruta-Agent-Verified | true or false, on a signed request: whether its Web Bot Auth or UCP signature verified |
X-Enruta-Signature-Agent | The verified agent origin, such as https://chatgpt.com, once the Web Bot Auth signature verified |
X-Enruta-Agent-Profile | The 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-Valid | true 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:
Signature-InputandSignatureparse, the signature uses the tagweb-bot-auth, and it carriescreated,expiresandkeyid.- It covers
@authority(or@target-uri) and theSignature-Agentmember. - The agent origin named in
Signature-Agentis an https origin that passes yourallowanddenylists. createdandexpireshold within 60 seconds of clock skew, and the window between them is at most 24 hours.- 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 equalskeyid. - 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
}status | When | reason, for example |
|---|---|---|
invalid | The signature, its parameters, its window or its key fail | invalid_signature, expired, directory_signature_invalid, nonce_replayed |
unverified | Not enough to decide: no such key, no directory, or an origin that is not allowed | unknown_key, directory_unavailable, agent_not_allowed |
unsigned | The 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:
Signature-InputandSignatureparse, the signature is untagged or taggedweb-bot-auth(signatures with other tags are left to other verifiers), and it carrieskeyid.- The
profileinUCP-Agentis an https URL on a public host: not an IP literal or a local name, no credentials, at most 1024 characters. createdandexpiresare optional; when present they hold within 60 seconds of clock skew. UCP leaves replay protection to the signedIdempotency-Key.- It covers
@method,@authorityand@path;@querywhen the URL has a query string;content-digestandcontent-typewhen the request has a body; andidempotency-key,ucp-agentandsignature-agentwhen the request carries those headers. Content-Digesthas asha-256member that matches the raw body bytes.- The profile publishes a key whose
kidiskeyid, inkeysorsigning_keys, at the top level or underucp. - 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
}status | When | reason, for example |
|---|---|---|
invalid | The signature, its parameters, its covered components, its digest or its key fail | missing_component, digest_mismatch, expired, unsupported_alg, invalid_signature |
unverified | Not enough to decide: no UCP-Agent profile, a profile that cannot be fetched, no such key, or a body too large to digest | ucp_agent_missing, profile_unavailable, unknown_key, body_unavailable |
unsigned | The 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.
| Route | Purpose |
|---|---|
POST /v0/sellers/orders | Report (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}/economics | The 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/domains | List 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/middleware | Install 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’smeta["ucp-agent"](only theUCP-Agentheader’s profile resolves a key), ES384 keys and response signatures. - Web Bot Auth keys named through
jwks_uriorcimddiscovery (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 sharednonceStore, 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.