Home/Docs/Playbook

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.

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.
The end state: your 403s convert into verified, mandate-scoped buyers.

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:

terminallive sandbox
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:

terminalPOST /v1/signup
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:

terminallocal
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.

Key hygiene

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:

terminal
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:

server.ts · Hono@writhq/verify
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.

terminal
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:

terminal
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:

terminal
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.

Exit criteria

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:

POST/v1/verifications/{id}/countersign
request body
{ "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:

GET/v1/verifications?limit=200  ·  max 1000, default 200

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 amount resolver reads what your ledger will actually move, not a client-declared field elsewhere in the body.
  • Fail-closed verified. Kill PASSPORT_URL in 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 on per_tx_cap bursts (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}/rotate for agent keys, POST /v1/platforms/self/rotate-key for 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_exceeded is 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.