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 #

Sign up at api.writhq.com/signup — platform name, email, and password. You get a plt_… id, an API key shown exactly once and stored only as a SHA-256 hash, so it can be rotated but never read back — not by you, not by support — and a login to the platform console. The key is your platform identity regardless of whether you hold an account: it authenticates your server-to-server calls to /v1/verify and scopes your decision log. There is no activation step; the key works on the next request. A confirmation link is emailed to you and it gates nothing — not the key, not the quota, not a single route. Confirming only clears a banner in the console and tells us we can reach you about an outage or a password reset; resend it any time with POST /v1/auth/verify-email. Same thing over the API:

POST/v1/auth/signup
terminalno auth
curl -s -X POST https://api.writhq.com/v1/auth/signup \
  -H "content-type: application/json" \
  -d '{"platform_name":"My Platform","email":"[email protected]","password":"…"}'
# → 201 {"account":{"id":"acc_…"},"platform":{"id":"plt_…","api_key":"plt_sk_…","plan":"free"}}

Don't want an account? POST /v1/signup is the original endpoint — platform name and email, nothing else — and still works exactly as before, returning a key with no account attached:

terminalno auth
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", …}

That's a fully supported steady state — nobody is forced to make an account, and a key created this way authenticates identically to one created through the account flow. Decide later that you want the console? Log in, create an account, and POST /v1/account/claim/platform {api_key} — claiming binds the platform to your account without changing, rotating, or invalidating the key; everything already deployed against it keeps working. A platform already claimed by a different account can't be re-claimed, even by someone holding the key (409).

New platforms start on the free plan — 1,000 verifications per calendar month, every feature included. See Plans & quotas. 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 in your environment, or pass apiKey / passportUrl explicitly. PASSPORT_URL defaults to the hosted passport at https://api.writhq.com; point it at http://localhost:3000 when you want the local stack instead.

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, except on a document.* route that supplies document. Resolver for the transaction amount in minor units.
document(ctx) => DocumentContextSignature authority only (document.* actions). Resolver for the document this route would sign — document_hash, document_class, counterparty, liability_minor. See Signature authority.
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 https://api.writhq.com.
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. For a document.* action the scope must also cover this document's class, or the decision is document_class.
  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 (a stub interface today).
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 document_class kyc_lapsed sanctions_hit

document_class belongs to signature authority (document.sign): the mandate covers document signing, but not paper of this class. A mandate with no document authority at all denies scope_not_found instead.

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

Nothing in this list is about billing. A 429 quota_exceeded is a limit on your account, carries no decision, and belongs to Plans & quotas instead.

Signature authority #

The same chain answers a second question: your agent signed it — was it authorized? Actions in the document.* namespace assert signing authority instead of spending authority. Writ attests the authority; it never produces the signature. Your e-signature platform still makes it, and none of this is a qualified electronic signature (eIDAS/QES).

Gate a signing route the way you gate a payment route — a document resolver replaces amount, because a signing call states its number once, as liability_minor:

server.tsplatform
app.post("/api/sign",
  requireKYA({
    action: "document.sign",
    document: async (c) => (await c.req.json()).document,
  }),
  handler,   // only runs when the agent had the authority; fails closed
);

The document block travels in the agent's signed assertion and in your verify call. If they differ, the decision is context_mismatch. We never receive the document — only its SHA-256.

document
{
  "document_hash": "<lowercase hex sha256 of the document bytes>",
  "document_class": "nda",        // nda · msa · sow · order_form · dpa · other
  "counterparty": "Northbank Sandbox",
  "liability_minor": 2500000     // $25,000 of exposure
}

A signing mandate names the classes of paper the agent may sign, and re-reads the familiar caps as liability caps — max_amount_per_tx per document, max_amount_per_period cumulative. Same cap engine, same server-side counters as payments; only the unit changes.

mandate scopesigned grant
{
  "action": "document.sign",
  "document_classes": ["nda", "order_form"],
  "max_amount_per_tx": 5000000,       // $50,000 per document
  "max_amount_per_period": 20000000,  // $200,000 a month
  "period": "month",
  "currency": "USD",
  "platforms": ["plt_…"]
}

Three ways it stops: the wrong class of paper denies document_class, one document over the per-document cap denies per_tx_cap, and too much cumulative exposure denies period_cap. The hash, class, counterparty and liability are bound into the signed receipt and into your decision log, so the record names the exact paper — on denials too.

UETA §14 and ESIGN already recognise contracts formed by electronic agents, and agency law binds a principal only within the authority granted. This is the record of that authority.

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. Put your countersigning public key on file first — POST /v1/platforms/self/key with your API key, no operator in the loop — because 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. Log in to the platform dashboard for the same decision log with one-click JSON export and named API-key management alongside it.

Plans & quotas #

Plans meter verifications per UTC calendar month and nothing else. Every plan carries every feature — mandates, caps, revocation, countersigning, audit export, signature authority. The plan buys volume; nothing about the decision procedure changes with the tier. Every verification counts, allow and deny alike — a deny is the product working.

PlanPriceIncluded
free$01,000 verifications a month. What every new signup starts on.
growth$99 / month100,000 verifications a month. Upgrade yourself from the platform dashboard — Stripe Checkout, no sales step. A hard ceiling, not metered overage: you are never surprised by a bill.
above thattalk to usVolume pricing or a regional deployment: [email protected].

Once the month's allowance is spent, /v1/verify stops answering and returns 429:

responseHTTP 429
{
  "error": "…",
  "code": "quota_exceeded",
  "quota": { /* the limit, what you've used, when it resets */ },
  "upgrade": { /* where to get more */ }
}
// note what is NOT here: no "decision", no "reason", no "chain", no receipt
A quota 429 is not a deny

This distinction matters more than any other on this page. A deny is Writ's signed answer to "did this agent have the authority?" — HTTP 200, a decision, a reason, a receipt. A 429 quota_exceeded is an answer about your own billing account, formed before the assertion is even read: no verification happened, so there is no decision and no signed record. That is why the body carries no decision field at all — a client that switches on body.decision cannot silently mistake one for the other.

So: do not record it in your own logs as a deny, do not show the caller a KYA failure, and do not let it fall through into executing the action unverified. Handle it like any other billing-side outage — hold the action and retry, or route it to your manual path. The middleware still fails closed, so the request blocks either way; what you must not do is attribute the block to the agent.

Your platform record #

The platform console is login-first once you have an account: your platforms, plan + usage meter, named-key management, the decision log with JSON export, Stripe upgrade / manage-billing, and an account activity log (logins, claims, key create/revoke). Pasting a key still works there too, but it's now an explicitly read-only fallback view for platforms with no account attached. The same account facts are available at GET /v1/auth/session, authenticated by the session cookie, which returns the account plus the platforms and principals it owns.

Key-level facts are also available over the API, authenticated with any of your platform's keys:

GET/v1/platforms/self

Returns your plan, this month's usage, and billing state. Cheap enough to poll on a schedule if you want to alert before you hit the ceiling rather than after.

Named API keys

A platform can hold up to 10 active keys, each with its own label, creation time, last_used_at, and independent revocation — so a leaked or retiring key doesn't force you to rotate everyone else's. The original single key still works unchanged; the console shows it as key zero, labelled default.

named keysany active key
GET    /v1/platforms/self/keys
POST   /v1/platforms/self/keys                 { "label": "…" }
PATCH  /v1/platforms/self/keys/:keyId          { "label": "…" }
POST   /v1/platforms/self/keys/:keyId/revoke

All four are key-authed — any active key on the platform can call them, no browser needed. The session-authed equivalents the console itself uses live under /v1/account/platforms/:id/keys…, for callers who are logged in instead of holding a key.

last_used_at only moves on a verification call/v1/verify and the like — never on a dashboard read. That's what makes it trustworthy evidence for "is anything in production still using this key" before you revoke it.

A revoked key fails immediately: the very next request authenticated with it — including a call to /v1/verify — gets 401.

POST/v1/platforms/self/rotate-key

Rotates whichever key authenticated the request. Call it with the original key and it rotates that same column — unchanged behavior for every existing caller who only ever had the one key. Call it with a named key and that key is revoked and replaced under the same label, its siblings untouched. Either way: a new key once, old key dead immediately — there is no overlap window, because rotation exists for a key you believe is compromised and a grace period would defeat it. Write the new key to your secret store and redeploy in the same motion; roll one key at a time. Since only a hash of any key is stored, this doubles as the recovery path for a lost key: rotate, don't ask.

Not to be confused with POST /v1/platforms/self/key, which rotates the countersigning public key the passport checks your countersignatures against. Different credential, different purpose.

Billing follows the account; the subscription follows the platform

If you've created an account, the Stripe customer object attaches to it — one customer, carrying the account's email — rather than to each platform, so managing billing across several platforms doesn't mean managing several customers. The subscription itself stays per-platform, because what's sold is one platform's monthly verification quota. If your platform predates accounts, none of this changes anything for you: you keep your existing platform-level customer and keep billing correctly.