Onboard your first API
Take one endpoint from "blocks agent traffic" to "verifies it" — a guarded route, a test agent making signed calls, caps enforced at the boundary, and an audit log of every decision. Budget about 30 minutes.
Pick one route to start — the one where agent traffic already shows up and gets rejected. The refill/deposit primitive is the sweet spot: account top-ups, API-credit refills, ad-budget top-ups, order placement. Nothing here moves real money: KYC is a sandbox stub and the sanctions screen is a stub.
Step 0 — See the end state #
2 minutes, nothing to install. Northbank is a live demo brokerage whose /api/refill is guarded by the exact middleware you're about to add. Hit it anonymously and watch it fail closed:
curl -i -X POST https://northbank-production.up.railway.app/api/refill \
-d '{"amount_minor":50000}'
# → HTTP/2 403
# {"error":"KYA required","reason":"missing_passport",
# "onboarding_url":"https://api.writhq.com/onboarding"}
That 403 — with an onboarding link instead of a dead end — is what your unguarded route is missing today.
Step 1 — Get your platform record and API key #
5 minutes. Your platform is a plt_ record on the passport plus an API key that authenticates your server-to-server calls to /v1/verify.
On the hosted service: sign yourself up at api.writhq.com/signup. Three fields — platform name, email, and password — and you get a plt_ id, an API key shown exactly once, and a login to the dashboard. No invite, no admin token, no waitlist, no activation step. A confirmation link is emailed to you, but it is asynchronous and gates nothing — your key verifies immediately, your quota is unaffected, and confirming only clears a banner in the console (resend it with POST /v1/auth/verify-email).
Already hold a key from before accounts existed? Log in, create an account, and claim it with POST /v1/account/claim/platform {api_key} — that binds it to your account without changing, rotating, or invalidating the key itself; everything already deployed against it keeps working.
Don't want an account at all? The original key-only endpoint still works exactly as before:
curl -s -X POST https://api.writhq.com/v1/signup \
-H "content-type: application/json" \
-d '{"name":"My Platform","email":"[email protected]"}'
# → 201 {"id":"plt_…","api_key":"plt_sk_…","plan":"free", …}
You land on the free plan: 1,000 verifications per calendar month with every feature on. Plans & quotas →
Or run the stack yourself: the whole thing runs on your machine, and you provision against your own instance:
git clone https://github.com/Zilula/writ && cd writ && npm install
npm run dev # passport :3000 · northbank :3100
# register your platform — admin-gated. Set PASSPORT_ADMIN_TOKEN yourself:
# there is no dev fallback, and an unset token closes the admin surface entirely.
curl -s -X POST http://localhost:3000/v1/platforms \
-H "authorization: Bearer $PASSPORT_ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{"name":"My Platform"}'
# → 201 {"id":"plt_…","api_key":"plt_sk_…","plan":"internal","created":true}
A key is plt_sk_ followed by 32 CSPRNG bytes in base64url. Platforms registered this way land on the unmetered internal plan — the operator's door, not the metered self-serve one.
Keep the key in your secret store — the passport keeps only a hash of it at rest, so it can never be read back to you. Lost or leaked, rotate it yourself: POST /v1/platforms/self/rotate-key returns a new key once and kills the old one immediately.
Running more than one service against the same platform? Give each its own named key instead of sharing one — up to 10 active keys per platform, each with a label and its own last_used_at, so you can tell what's still in use before you revoke it (GET/POST /v1/platforms/self/keys, POST /v1/platforms/self/keys/:keyId/revoke).
Step 2 — Guard the route #
10 minutes. Install the middleware and set two env vars in your service:
npm i @writhq/verify
# env
PASSPORT_URL=http://localhost:3000 # or the hosted passport URL
PLATFORM_API_KEY=plt_sk_…
Then wrap the route. Hono shown; requireKYAExpress is the Express twin, and evaluateRequest / KYAClient cover everything else:
import { requireKYA } from "@writhq/verify";
app.post(
"/api/refill",
requireKYA({
action: "account.refill",
// bind the REAL amount (minor units) — caps are enforced against this
amount: async (c) => {
const body = await c.req.json().catch(() => ({}));
return Number(body.amount_minor ?? 0);
},
}),
async (c) => {
const kya = c.get("kya"); // decision, chain, verification_id, receipt
await accounts.refill(/* … */); // your existing logic, unchanged
return c.json({ ok: true, principal: kya.chain.principal });
}
);
The middleware fails closed: missing header, deny, or an unreachable passport all block the request before your handler runs.
Step 3 — Confirm the block #
2 minutes.
curl -i -X POST http://localhost:4000/api/refill -d '{"amount_minor":50000}'
# → 403 {"error":"KYA required","reason":"missing_passport","onboarding_url":"…"}
Set onboardingUrl in the middleware options to your co-branded flow when you have one — the funnel is anonymous → 403 → onboard → mandate → verified buyer.
Step 4 — Run the full chain with a test agent #
8 minutes. Provision a principal, agent, and mandate, then present signed assertions at your route. From the repo checkout:
export PASSPORT_AGENT_HOME="$PWD/.passport-agent" # one keystore for every call
# principal (sandbox KYC → verified) + agent + mandate in one shot:
npm run seed
Point the agent at your platform (the seeded default is Northbank). The mandate's scope must name your plt_ id, and the refill command presents to {--url}/api/refill:
npm run cli -w @passport/agent -- use --mandate mnd_… --platform plt_…
npm run cli -w @passport/agent -- refill --amount 500 --url http://localhost:4000 # → allow
npm run cli -w @passport/agent -- refill --amount 2000 --url http://localhost:4000 # → deny · per_tx_cap
If your route isn't /api/refill, present from code instead — @writhq/sdk's PassportAgent.load(…) then agent.present(url, { action, amount }) hits any endpoint — or drive it from Claude Code with npx @writhq/mcp. Then revoke the mandate (POST /v1/mandates/{id}/revoke, or one click in the principal dashboard) and watch the next present deny with mandate_revoked within seconds — revocation is a live check, not a cached one.
You have seen allow, per_tx_cap, and mandate_revoked at your own route, and each response carried the resolved chain + a signed receipt.
Step 5 — Countersign and audit #
3 minutes. After your handler executes the action, countersign the verification — a record signed by both sides is nearly incontestable:
{ "countersig": "<compact JWS signed by your platform key>" }
The JWS is over {verification_id, decision, platform, platform_ref, ts}, signed with your own platform key — put platform_ref to work by storing your ledger's id for the executed action there. Get your countersigning public key on file first, which is self-serve: POST /v1/platforms/self/key with your API key. Without it every countersignature is refused 422.
Export the decision log any time for reconciliation or compliance review — every decision is an immutable, signed vrf_ record:
There is no date or platform filter, and none is needed: the export is scoped server-side to the platform your API key resolves to, so your key returns your records and nothing else.
The platform dashboard (/platform on the passport) renders the same log with co-signature status and one-click JSON export. Log in there for the full console — plan, usage, named-key management, and an account activity log alongside the decision log; pasting a key without an account still gives you a read-only view of the same log.
Step 6 — Production checklist #
- Caps make sense. Per-tx and period caps agreed with your principals — start narrow; widening a mandate is easy, clawing back is not.
- Amount binding is real. The
amountresolver reads what your ledger will actually move, not a client-declared field elsewhere in the body. - Fail-closed verified. Kill
PASSPORT_URLin staging and confirm the route blocks instead of admitting. - Receipts verified. Check the receipt JWS against the issuer JWKS:
GET /v1/issuer/jwks(Ed25519). - Countersigning wired into the post-execute path, not best-effort.
- Deny monitoring. Alert on spikes of
replay/unknown_agent(probing) and onper_tx_capbursts (an agent mis-sized for its mandate). - Onboarding URL points somewhere real, so blocked traffic converts.
- Key hygiene. Platform API key in a secret store; rotation path tested (
POST /v1/agents/{id}/rotatefor agent keys,POST /v1/platforms/self/rotate-keyfor yours). Running multiple services? Split them onto named keys so one can be revoked without rotating everyone else's. - Quota headroom. Watch usage on the platform dashboard — a
429 quota_exceededis a billing limit, not a deny, and your handler must treat it as "not evaluated". See Plans & quotas.
What to integrate second #
More actions on the same platform record — each is one more requireKYA({ action }) line and one more scope in the mandate schema: order.place, subscription.create, credits.refill. The chain, caps, revocation, and audit come along for free.