Home/Docs/For e-signature platforms

Integrate as an e-signature platform

Verify signing authority before you execute a signature. One middleware in front of the endpoint that applies the seal — it resolves the agent's mandate, binds the document's SHA-256 into a signed record, and fails closed.

You run signing infrastructure. An API request arrives asking you to execute a signature on behalf of Acme Corp — and it was made by an agent, not a person at a keyboard. Two questions have to be answered before you seal anything, and only one of them is yours:

  1. Is this really Acme's agent? — authentication. Your problem, and you already solve it.
  2. Was this agent actually authorized by Acme to sign paper of this kind, at this exposure, today?attribution. Not your problem to solve, and not something a bearer token can answer.

Writ answers the second one in one round trip and hands you a signed record of the answer. Add one middleware in front of the endpoint that executes a signature; it fails closed.

The boundary, stated once

Writ attests authority. It never produces a signature — your platform still does that, exactly as it does today, with the same certificate and the same audit trail. Nothing here is a qualified electronic signature (eIDAS/QES) and it does not try to be.

Why attribution is the whole game #

ESIGN already contemplates this. 15 U.S.C. §7001(h):

ESIGN · 15 U.S.C. §7001(h)

A contract or other record relating to a transaction in or affecting interstate or foreign commerce may not be denied legal effect, validity, or enforceability solely because its formation, creation, or delivery involved the action of one or more electronic agents so long as the action of any such electronic agent is legally attributable to the person to be bound.

UETA §14 says the same thing for the states that adopted it: a contract may be formed by the interaction of electronic agents, even where no individual reviewed the terms.

Read the ESIGN clause again and notice where the weight sits. Agent-formed contracts are already enforceable — the statute says so — conditioned entirely on the last clause. Attribution is the load-bearing element, and neither statute tells you how to establish it. Agency law does the rest of the work, and it is unromantic: a principal is bound within the authority actually granted, and not one dollar beyond it.

So the question a court asks about an agent-signed contract is not "was there a signature?" It is "what authority did this agent have, who granted it, and when?" Today, for almost every agent-signed document in existence, the honest answer is a screenshot of an API log.

A Writ record answers it in the form the question is asked: a mandate, signed by an issuer, from a KYC'd principal, naming the classes of paper the agent may sign, the per-document and cumulative liability it may commit, the platforms it may act at, and when the grant expires — plus an immutable decision, signed by both Writ and you, saying that this specific document, identified by hash, fell inside it.

That is the artifact you want in the file when a signature is challenged eighteen months later. It is also the artifact your enterprise customers will start asking you for the first time one of their agents signs something nobody remembers approving.

Install & get keys #

Sign up at api.writhq.com/signup — platform name, email, password. You get a plt_… id, an API key shown exactly once and stored only as a SHA-256 hash, and a login to the platform console. No activation step, no waiting: the key works on the next request.

terminal
npm i @writhq/verify

New platforms start on the free plan — 1,000 verifications per calendar month, every feature included, signature authority among them. There is no separate e-signature tier; see Plans & quotas.

Where the gate goes #

This is the one design decision that matters, and getting it wrong makes the whole record worthless.

Gate the endpoint that executes the signature — the moment the seal is applied — not the endpoint that creates a draft, uploads a document, or invites a signer.

request pathgate placement
 agent → POST /envelopes            (draft; no gate needed)
 agent → POST /envelopes/:id/fields (fill; no gate needed)
 agent → POST /envelopes/:id/sign   ← GATE HERE. This is the act that binds.

Two reasons, and the second is the one that shows up in a dispute:

  1. Liability attaches at the seal. A draft binds nobody. The verify call must describe the act that has legal effect, or the record describes something that never mattered.
  2. The document must be final when you hash it. The verify call carries a SHA-256 of the exact bytes you are about to seal. Hash a draft, let three fields change, then seal — and you have a signed record attesting to a document that does not exist. The hash has to be taken of the final bytes, in the same request that seals them.

If your product has a batch-send or bulk-execute path, gate each envelope individually. One verify per document is what makes the per-document liability cap mean anything.

The middleware #

Same shape as any other Writ integration, with a document resolver instead of amount:

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

app.post(
  "/envelopes/:id/sign",
  requireKYA({
    action: "document.sign",
    // Read from the REAL envelope, at the moment of sealing — never from
    // anything the caller declared about it.
    document: async (c) => {
      const envelope = await envelopes.load(c.req.param("id"));
      const bytes = await envelope.renderFinalPdf();
      return {
        document_hash: await hashDocument(bytes),
        document_class: envelope.class,          // nda · msa · sow · order_form · dpa · other
        counterparty: envelope.counterpartyName,
        liability_minor: envelope.contractValueMinor,
      };
    },
  }),
  async (c) => {
    // Only runs when the agent had the authority. Fails closed otherwise.
    const { chain, verification_id } = c.get("kya");
    const sealed = await envelopes.seal(c.req.param("id"));
    return c.json({ ok: true, envelope: sealed.id, writ_verification: verification_id });
  }
);

Express is requireKYAExpress with the same options; everything else uses the framework-agnostic evaluateRequest. There is no failOpen — a missing header, a deny, or an unreachable passport all block the request.

Resolve the document from your own state, never from the request body #

The resolver above loads the envelope and re-renders it. That is deliberate. If you take document_hash, document_class or liability_minor from the JSON the agent sent you, an agent with a $10,000 mandate can present a $2,000,000 MSA as an nda worth $500 and you will have a signed record saying Writ approved it. Writ can only attest to the context you give it.

Every field in that resolver must come from the system of record:

FieldWhere it must come from
document_hashSHA-256 of the final bytes you are about to seal, hashed in this request
document_classYour envelope's own classification, or the template it was created from
counterpartyThe counterparty on the envelope
liability_minorThe contract value your system holds, in minor units

The agent independently states the same four values inside its signed assertion. If yours and its disagree, the decision is context_mismatch and nothing is sealed — which is exactly the protection this design exists to provide.

The document hash #

The passport never receives the document. It receives a lowercase hex SHA-256 of the bytes, and nothing else:

hash.ts@writhq/verify
import { hashDocument } from "@writhq/verify";

const digest = await hashDocument(finalPdfBytes);
// "9f2c…" — 64 hex characters

hashDocument accepts a string, Uint8Array, or ArrayBuffer. Hash the rendered, final artifact — the same bytes you are about to store and serve — not the template, not the field values, not a JSON representation.

This is the property that makes the record durable: two years from now, anyone holding the PDF can recompute the digest and match it against the receipt, without asking you or us for anything. Your customers' auditors can verify a Writ record offline against the published JWKS at /v1/issuer/jwks.

The verify call #

The middleware makes this for you. The raw contract:

POST/v1/verify

Authorization: Bearer <your platform API key>

request body
{
  "assertion": "<JWS signed by the agent key>",
  "action": "document.sign",
  "currency": "USD",
  "document": {
    "document_hash": "<lowercase hex sha256 of the final document bytes>",
    "document_class": "msa",
    "counterparty": "Northwind Logistics GmbH",
    "liability_minor": 250000000
  }
}

amount is optional when document is present — a signing call states its number once, as liability_minor. Send both and they must be equal, or the body is rejected 422.

Response:

200 · allowreceipt = JWS
{
  "decision": "allow",
  "reason": "ok",
  "chain": {
    "principal": { "type": "entity", "country": "US",
                    "kyc": "verified", "accredited": false },
    "agent": { "id": "agt_9f…", "name": "contracts-bot", "runtime": "claude-code" },
    "mandate": { "id": "mnd_tr7…", "remaining_this_period": 750000000 }
  },
  "verification_id": "vrf_9c1…",
  "receipt": "<JWS signed by Writ, binding the document block>"
}

You receive attributes — KYC level, country, entity type — never the principal's identity documents. remaining_this_period is remaining liability headroom, in minor units, which is a genuinely useful thing to surface in your own UI: "this agent may commit $7.5M more this month."

Mandates for signing authority #

Your customers issue these, from the permissions console or the API. You do not issue them and cannot — that separation is the point. But you will be asked what a good one looks like, so here are the three that cover most of what an e-signature platform sees.

Routine vendor paperwork. An agent that may sign NDAs and order forms, nothing else, with real ceilings:

mandate scopesigned grant
{
  "action": "document.sign",
  "document_classes": ["nda", "order_form"],
  "max_amount_per_tx": 5000000,
  "max_amount_per_period": 20000000,
  "period": "month",
  "currency": "USD",
  "platforms": ["plt_your_platform"],
  "purpose": "vendor-paperwork"
}

$50,000 of exposure per document, $200,000 a month, at your platform and nowhere else.

NDAs only, unlimited in practice. The most common first grant, because an NDA is the paper companies are most comfortable letting an agent handle:

mandate scopesigned grant
{
  "action": "document.sign",
  "document_classes": ["nda"],
  "max_amount_per_tx": 100000,
  "max_amount_per_period": 10000000,
  "period": "month",
  "currency": "USD",
  "platforms": ["plt_your_platform"],
  "purpose": "inbound-nda-desk"
}

Data processing agreements, one per quarter, tightly bounded. Note the period — day, week and month are the vocabulary; a quarterly cadence is expressed as a monthly ceiling that only one document will reach.

mandate scopesigned grant
{
  "action": "document.sign",
  "document_classes": ["dpa"],
  "max_amount_per_tx": 0,
  "max_amount_per_period": 0,
  "period": "month",
  "currency": "USD",
  "platforms": ["plt_your_platform"],
  "purpose": "dpa-countersignature"
}

A cap of 0 is legitimate and means what it says: this paper carries no monetary liability, and the grant is about the class, not the exposure. max_amount_per_tx: 0 allows a document whose liability_minor is 0 and denies every other one.

What the vocabulary is. document_classes is a closed set — nda · msa · sow · order_form · dpa · other — and it is required on any document.* action. A signing scope that names no class is rejected 422 at issuance, because it could only ever deny. Conversely, document_classes on a payment scope is also rejected: it would look like a constraint while constraining nothing.

There is no "may sign anything"

It is not expressible. That is not an oversight.

How it denies, and what to tell the caller #

allow always carries reason ok. Everything else is a deny, and for a signing route these five are the ones you will actually see:

ReasonWhat happenedWhat to show the agent's operator
document_classThe mandate grants signing authority, but not for paper of this class."Your agent may sign NDAs; this is an MSA. Ask your administrator to widen the mandate."
per_tx_capThis one document exceeds the per-document liability cap."This contract's value exceeds the agent's per-document limit of $X."
period_capThis document would push cumulative exposure past the period cap."Your agent has $X of signing headroom left this month."
scope_not_foundNo document authority at all — the mandate is payments-only."This agent has no signing authority. A new mandate is needed."
context_mismatchThe agent signed for a different document than the one you presented."The document changed after the agent reviewed it. Re-request."

The full closed set is on the platform guide. Treat a reason you don't recognize as a deny — the set can grow.

Do not conflate a deny with an error. A deny is a 200 with a decision field and a signed receipt; it is the product working, it is billable, and it belongs in your envelope's history as "blocked: agent lacked authority" rather than as a 500.

A quota 429 is not a deny

429 quota_exceeded is about your Writ billing account, is formed before the assertion is even read, and carries no decision field at all. Hold the envelope and retry, or route it to your manual path — but never tell a customer their agent lacked authority because your invoice lapsed.

The 403 "KYA required" block page #

A request with no X-Passport header is an anonymous agent, and your handler never runs. The middleware returns a structured 403 with an onboarding link:

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 own co-branded flow. For an e-signature platform this is worth more than it first looks: the traffic you are currently rejecting as "unattended API access we can't accept liability for" becomes a funnel that ends with a KYC'd principal and a scoped mandate on file. Rejected agent traffic becomes onboarded agent traffic.

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

Countersign the executed signature #

After you seal, countersign the verification. This is the step that turns Writ's record into a two-party one, and for an e-signature platform it is the most valuable thing on this page.

POST/v1/verifications/{id}/countersign

Sign {verification_id, decision, platform, platform_ref, ts} with your platform key and post the compact JWS. Put your envelope id in platform_ref. That single field is what ties the Writ record to the executed document in your system, in both directions, permanently:

countersign.ts@writhq/verify
import { countersign } from "@writhq/verify";

const jws = await countersign(
  {
    verification_id,
    decision: "allow",
    platform: "plt_your_platform",
    platform_ref: sealed.id,        // ← your envelope id. Do not skip this.
    ts: new Date().toISOString(),
  },
  platformPrivateKey
);

await fetch(`https://api.writhq.com/v1/verifications/${verification_id}/countersign`, {
  method: "POST",
  headers: { authorization: `Bearer ${PLATFORM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ countersig: jws }),
});

Register your platform's countersigning public key first — POST /v1/platforms/self/key with your API key — or every countersignature is rejected 422. If you ever see invalid countersignature (does not verify against platform key), the response tells you which key is on file so you can compare.

Store verification_id on your envelope. It is one string, and it is the entire index into the authority record.

What the co-signed record proves in a dispute #

Eighteen months later, Northwind's counsel writes to say the MSA is not binding because nobody at Acme ever approved it. Here is what exists, and what each part answers:

ArtifactSigned byWhat it establishes
mandate · mnd_…Writ's issuance keyAcme, a KYC'd entity, granted this specific agent authority to sign msa paper up to $2.5M per document, at your platform, between these dates — and the grant was live at the moment in question.
receipt · vrf_…Writ's issuance keyAt 2026-08-19T14:22:07Z, a document with SHA-256 9f2c… and stated value $2.5M, counterparty Northwind Logistics GmbH, was evaluated against that mandate and allowed — with the agent's own signature over the same four facts.
countersignatureYour platform keyYou executed it, and your envelope env_8821 is that document.
the documentYour signing certificateThe bytes, whose hash still matches.

Recompute the SHA-256 of the PDF in the file. It matches the receipt. Fetch https://api.writhq.com/v1/issuer/jwks and verify Writ's two signatures offline; verify your own countersignature against your registered public key. Nothing in that chain requires anyone to trust your logs, or ours.

The claim "nobody approved it" then has to contend with a signed grant, from a verified legal entity, naming the class of paper and the ceiling, issued before the fact — which is the evidentiary posture ESIGN §7001(h) is asking for when it conditions enforceability on the action being legally attributable to the person to be bound.

What it does not establish

That the terms were fair, that the principal read them, or that the agent exercised good judgment. Authority is not consent to every term, and Writ makes no claim about the contents of a document it has never seen. A revoked mandate does not unwind a signature already executed under a live one — it stops the next one, immediately. That is the correct behaviour and worth telling your customers plainly.

Export #

Every decision is an immutable vrf_… record, scoped to your key:

GET/v1/verifications?limit=200

Send Authorization: Bearer <your platform API key>; limit maxes out at 1000. Rows for document.* actions carry the full document block — class, hash, counterparty, liability — alongside the decision, the chain and both signatures. The platform console renders the same log with a one-click JSON export, and shows the document class and hash prefix for signing decisions rather than only the amount.

Denials are exported too, and they are worth keeping: a row showing an agent tried to sign a $4M MSA against a $2.5M cap and was refused is the clearest possible evidence that the ceiling was real.

Try it end to end #

terminalno signup
npx @writhq/demo sign

Runs the whole signature-authority path against production with a throwaway principal, agent and mandate: registers the chain, signs an NDA inside the grant, is refused an MSA outside it, is refused an over-cap document, and countersigns the one that succeeded — thirteen checks, no signup required.