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:
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:
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 });
}
);
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 #
| Option | Type | Description |
|---|---|---|
| action | string | Required. The scope action this route performs, e.g. account.refill. |
| amount | (ctx) => number | Required. Resolver for the transaction amount in minor units. |
| currency | string | Expected currency. Defaults to "USD". |
| onboardingUrl | string | Where blocked/anonymous traffic is sent to get a passport. Defaults to {passportUrl}/onboarding. |
| passportUrl | string | Base URL of the passport service. Defaults to env PASSPORT_URL, else http://localhost:3000. |
| apiKey | string | Your 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.
{
"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:
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.
Request — Authorization: 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:
{
"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:
{
"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>"
}
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:
- Agent signature — the assertion verifies against the registered agent public key.
- Replay — timestamp within a ±120s window and the nonce is single-use.
- Mandate liveness — active, not expired, not revoked (a live check — revocation is instant).
- Scope — the mandate covers this action, amount, and platform.
- Period counters — cumulative spend this period stays under the cap (we maintain the counters).
- KYC status — the principal's KYC has not lapsed.
- Sanctions — screen passes (stub interface in the alpha).
Decision reasons #
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.
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.
{
"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:
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.