Home/Docs/For platforms

Integrate as a platform

Add one middleware in front of the routes agents call. It verifies the full chain of agency in one round trip and fails closed — so you can accept agent-originated volume you'd otherwise reject as fraud.

Install & get keys #

Create a platform (plt_…) in the platform dashboard and mint an API key. The key authenticates your server-to-server calls to /v1/verify. Then install the middleware:

terminal
npm i @writhq/verify

The package ships requireKYA (Hono), requireKYAExpress (Express), and the framework-agnostic evaluateRequest + KYAClient for everything else. Set PLATFORM_API_KEY and PASSPORT_URL (defaults to http://localhost:3000 for local dev) in your environment, or pass apiKey / passportUrl explicitly.

The requireKYA middleware #

Guard a route by naming the action it performs. The middleware reads the X-Passport header, calls verify, and either attaches the resolved chain to the request or short-circuits with a deny / 403. Roughly ten lines to integrate:

server.ts · Hono@writhq/verify
import { requireKYA } from "@writhq/verify";

// Accept agent traffic — one round trip, fails closed.
app.post(
  "/api/refill",
  requireKYA({
    action: "account.refill",
    // bind the REAL amount (minor units) into the verify call
    amount: async (c) => {
      const body = await c.req.json().catch(() => ({}));
      return Number(body.amount_minor ?? 0);
    },
  }),
  async (c) => {
    const { chain, verification_id } = c.get("kya");
    await accounts.refill(await c.req.json());
    return c.json({ ok: true, principal: chain.principal });
  }
);
Amount binding

The amount resolver is required and returns minor units. The middleware binds it into the verify call, so the passport enforces per-tx and period caps against the real amount — never a client-declared one.

Options #

OptionTypeDescription
actionstringRequired. The scope action this route performs, e.g. account.refill.
amount(ctx) => numberRequired. Resolver for the transaction amount in minor units.
currencystringExpected currency. Defaults to "USD".
onboardingUrlstringWhere blocked/anonymous traffic is sent to get a passport. Defaults to {passportUrl}/onboarding.
passportUrlstringBase URL of the passport service. Defaults to env PASSPORT_URL, else http://localhost:3000.
apiKeystringYour platform API key for /v1/verify. Defaults to env PLATFORM_API_KEY.

There is no failOpen. The middleware always fails closed — a missing header, a deny, or an unreachable passport all block the request. To customize the deny response, wrap the framework-agnostic evaluateRequest instead of the adapter.

The 403 "KYA required" block page #

When a request arrives with no X-Passport header — an anonymous agent — the middleware never runs your handler. It returns a structured 403 with an onboarding link, so rejected traffic becomes a lead instead of a dead end.

responseHTTP 403
{
  "error": "KYA required",
  "message": "This action requires a verified agent passport.",
  "reason": "missing_passport",
  "onboarding_url": "https://api.writhq.com/onboarding"
}

Point onboarding_url at your co-branded onboarding flow. The funnel:

Funnel: an anonymous agent with no X-Passport header gets a structured 403 with an onboarding URL; the principal onboards through KYC, a mandate is issued, and the agent returns as a verified buyer with co-signed receipts.
Rejected traffic becomes leads: anonymous → 403 → onboard → mandate → verified buyer.

Verify API #

The middleware calls this for you, but here is the raw contract for custom integrations. One round trip.

POST/v1/verify

RequestAuthorization: Bearer <your platform API key> (the key is your platform identity — you never declare platform_id yourself), plus the raw agent assertion and the context you are about to execute:

request body
{
  "assertion": "<JWS signed by the agent key>",
  "action": "account.refill",
  "amount": 50000,
  "currency": "USD"
}

Response — a decision, the resolved chain (attributes only), a verification id, and a signed receipt:

200 · allowreceipt = JWS
{
  "decision": "allow",
  "reason": "ok",
  "chain": {
    "principal": { "type": "individual", "country": "US",
                    "kyc": "verified", "accredited": true },
    "agent": { "id": "agt_9f…", "name": "treasury-bot", "runtime": "claude-code" },
    "mandate": { "id": "mnd_tr7…", "remaining_this_period": 150000 }
  },
  "verification_id": "vrf_9c1…",
  "receipt": "<JWS signed by passport>"
}
Selective disclosure

You receive attributes — KYC level, country, an accreditation flag — never the principal's identity documents, unless the mandate grants disclosure or a lawful request compels it. Subject access, correction, and dispute flows are first-class and FCRA-shaped.

The check pipeline #

On every verify the passport runs, in order — first failure decides:

  1. Agent signature — the assertion verifies against the registered agent public key.
  2. Replay — timestamp within a ±120s window and the nonce is single-use.
  3. Mandate liveness — active, not expired, not revoked (a live check — revocation is instant).
  4. Scope — the mandate covers this action, amount, and platform.
  5. Period counters — cumulative spend this period stays under the cap (we maintain the counters).
  6. KYC status — the principal's KYC has not lapsed.
  7. Sanctions — screen passes (stub interface in the alpha).
Flowchart of the verify pipeline: seven ordered checks — agent signature, freshness, mandate liveness, scope, caps, KYC, sanctions — each failing straight to its deny reason; passing all seven returns allow with the resolved chain and a signed receipt.
First failure decides — every deny carries the reason for the earliest failed check.

Decision reasons #

ok unknown_agent bad_signature replay stale mandate_not_found mandate_revoked mandate_expired mandate_not_yet_valid agent_mismatch principal_mismatch context_mismatch scope_not_found wrong_platform currency_mismatch per_tx_cap period_cap kyc_lapsed sanctions_hit

allow always carries ok. Any other reason accompanies a deny. Treat a reason you don't recognize as a deny — the set can grow.

Countersigning #

After you execute the action, countersign the verification. A record signed by both sides — the passport's receipt plus your platform key — is nearly incontestable. This is the evidence-layer law, on from day zero.

POST/v1/verifications/{id}/countersign

Sign the claims {verification_id, decision, platform, platform_ref, ts} with your platform key and post the compact JWS. Register your platform public key with us first — the passport verifies the countersignature against it, and rejects one that verifies but whose claims name a different verification or platform.

request body
{
  "countersig": "<JWS signed by your platform key>"
}
// → { "id": "vrf_9c1…", "countersigned": true,
//      "countersigned_at": "…", "both_signed": true }

Audit export #

Every decision is an immutable, signed vrf_… record. Export the log for reconciliation, dispute handling, or compliance review:

GET/v1/verifications?limit=200

Send Authorization: Bearer <your platform API key>; limit maxes out at 1000. The export is scoped to the calling key — your key returns only your platform's records, and cannot countersign another platform's verification. The platform dashboard renders the same decision log with API-key management alongside it.