API reference
The settlement assurance layer for the agent economy. Escrows lock simulated funds, deliveries are verified against machine-readable terms, and disputes resolve only by co-signature, arbiter signature or timeout default. Every closed matter issues a quitanza, a signed, independently verifiable settlement proof. Simulated rails: no real funds. Hosted deployments require Authorization: Bearer <api key> on every /v1 route (401 otherwise) and rate-limit /v1 per client (429 over budget); a local sandbox without QUITANZA_API_KEY runs open.
This page is generated from the OpenAPI 3.1 document, the hand-written source of truth. The running API serves it at GET /v1/openapi.json; a copy is published at https://quitanza.com/openapi.json. Start the local sandbox with pnpm --filter @quitanza/api dev (default port 4280).
Agents
Sandbox-only key registration. The sandbox holds these keys server-side; production agents sign locally and never transmit private keys.
POST /v1/agents
Register a sandbox agent.
Generates an Ed25519 keypair held server-side. Sandbox convenience only: production agents keep keys client-side and sign locally.
{
"label": "buyer-agent"
}
Responses:
201: Agent registered; only the public key is returned.
Mandates
Principal-signed caps binding an agent key: per-escrow max, cumulative max, allowed assets, optional counterparty allowlist, expiry. Optional in the sandbox; when present, every cap is enforced at escrow creation.
POST /v1/mandates
Register a principal-signed mandate.
The signature must be the principal's, over the canonical JSON of {principal, caps}. Registration rejects anything else; creation-time enforcement then holds every escrow under the mandate to its caps. Registration is idempotent by content hash: submitting an identical mandate again returns the existing record with its usage intact.
Responses:
201: Mandate accepted.hashis the content hash referenced by trails and quitanzas.400: bad_request: missing fields.403: mandate_violation: the signature is not the principal's or does not verify.
GET /v1/mandates/{id}
Fetch a mandate with its usage.
- Path
id: Mandate id (mnd_…)
Responses:
200: The mandate, its hash, and cumulative usage.404: not_found
Escrows
The escrow lifecycle: created → funded → delivered → settled, with refund, dispute and timeout branches. Every state change lands on a hash-chained evidence trail.
POST /v1/escrows
Create an escrow.
Parties are sandbox agent ids (payerAgentId/payeeAgentId) or raw Ed25519 public keys (payer/payee). Optional: mandateId (caps enforced at creation), arbiter (whose signature alone can later rule on a dispute), timeouts (per-stage deadlines; defaults guarantee silence always resolves).
- Header
Idempotency-Key: Any unique string. The first successful response under a key is stored and replayed verbatim on retry, so a retried request can never create a duplicate.
{
"payerAgentId": "agt_…",
"payeeAgentId": "agt_…",
"amount": "25.00",
"asset": "USDC",
"terms": {
"description": "the agreed report, exactly",
"checks": [
{
"kind": "shape",
"requiredFields": [
"report"
]
}
]
}
}
Responses:
201: Escrow created; emits escrow.created.400: bad_request: missing terms or malformed party.403: mandate_violation: a cap would be exceeded.404: not_found: unknown agent or mandate.
GET /v1/escrows
List all escrows.
Responses:
200: Every escrow, any state.
GET /v1/escrows/{id}
Fetch one escrow.
- Path
id: Escrow id (esc_…)
Responses:
200: The escrow.404: not_found
POST /v1/escrows/{id}/fund
Mark funds locked (simulated).
Transitions created → funded, stamps the delivery deadline, emits escrow.funded. Local simulation: no real funds, ever.
- Path
id: Escrow id (esc_…) - Header
Idempotency-Key: Any unique string. The first successful response under a key is stored and replayed verbatim on retry, so a retried request can never create a duplicate.
Responses:
200: The funded escrow with its deadlineAt.404: not_found409: invalid_state: not in created state.
POST /v1/escrows/{id}/delivery
Submit a delivery; verification runs synchronously.
Two forms. Sandbox: { agentId, payload }, where the API signs with the named agent's held key. Client-signed: { payload, submittedAt, signature }, where signature is the payee's Ed25519 signature over canonical {escrowId, contentHash, submittedAt}. A passing verdict settles and the response includes the quitanza; a failing verdict leaves the escrow in delivered for dispute or refund.
- Path
id: Escrow id (esc_…)
Responses:
201: Delivery accepted and judged. Emits delivery.submitted, then verdict.passed (with quitanza.issued) or verdict.failed.404: not_found409: invalid_state: not in funded state.422: unprocessable: wrong signer or bad signature.
POST /v1/escrows/{id}/refund
Refund a funded or failed-verdict escrow.
Allowed from funded (no delivery yet) or delivered with a failed verdict. Issues a quitanza with outcome refunded: proof of how the matter closed.
- Path
id: Escrow id (esc_…)
Responses:
200: The refund quitanza.404: not_found409: invalid_state
GET /v1/escrows/{id}/custody
The custody journal entry for an escrow.
Present once a settlement has been attempted. A failed entry means custody (e.g. the chain) did not confirm: the escrow stays in its pre-terminal state, carries custody.status settle-failed, and a custody.failed event has fired. A confirmed entry records that the settlement went through.
- Path
id: Escrow id (esc_…)
Responses:
200: The journal entry.404: not_found: no settlement has been attempted.
POST /v1/escrows/{id}/custody/retry
Retry a failed custody settlement.
Re-runs the journaled settlement. Idempotent: a settlement custody has already confirmed returns its quitanza without touching custody again; a settlement is never silently lost and never double-sent. On success the escrow reaches its terminal state and the quitanza is issued.
- Path
id: Escrow id (esc_…)
Responses:
200: Settlement confirmed; the quitanza.404: not_found: no journaled settlement for this escrow.502: custody_failed: custody failed again; the journal keeps the entry for another retry.
GET /v1/escrows/{id}/trail
The hash-chained evidence trail.
Every state change as a chained entry: hash = sha256(canonical({index, prevHash, at, event, body})). intactUpTo is null when the chain verifies end to end, otherwise the index of the first break.
- Path
id: Escrow id (esc_…)
Responses:
200: Trail entries and chain status.404: not_found
Disputes
Structured challenges. A ruling is accepted only when co-signed by both parties or signed by the named arbiter; otherwise the timeout default applies. The platform never rules.
POST /v1/escrows/{id}/dispute
Open a dispute on a delivered escrow.
- Path
id: Escrow id (esc_…)
Responses:
201: Dispute opened; the decide-by deadline starts. Emits dispute.opened.404: not_found409: invalid_state: only delivered escrows can be disputed.422: unprocessable: opener is not a party.
GET /v1/escrows/{id}/dispute
Fetch the dispute on an escrow.
- Path
id: Escrow id (esc_…)
Responses:
200: The dispute.404: not_found
POST /v1/escrows/{id}/dispute/evidence
Attach content-addressed evidence.
- Path
id: Escrow id (esc_…)
Responses:
200: Evidence recorded by content hash. Emits dispute.evidence.404: not_found409: invalid_state
POST /v1/escrows/{id}/dispute/resolve
Submit a ruling: co-signed or arbiter-signed only.
Signers sign the canonical JSON of {escrowId, disputeId, outcome, rationale} and, for a split, its shares. Accepted only with both parties' signatures (co-signature) or the named arbiter's. A split ruling must carry shares: integer basis points {payerBps, payeeBps} summing to 10000; signatures commit to the exact division, and shares on any other outcome are rejected. signatures carries client-side signatures; agentIds asks the sandbox to sign with held agent keys. Anything else is rejected: the platform never rules. Disputes nobody resolves fall to the timeout default (a split default divides evenly).
- Path
id: Escrow id (esc_…)
Responses:
200: Ruling applied; the matter closes in a quitanza. Emits dispute.resolved and quitanza.issued.403: unauthorized_ruling: signatures are missing, invalid, or not from the required signers.404: not_found409: invalid_state
Quitanzas
Terminal settlement proofs: Ed25519-signed over canonical JSON, hash-chained to the evidence trail, verifiable offline.
GET /v1/log/head
The transparency log's signed tree head (public).
No authentication, rate limited per client IP. Every issued quitanza's hash is a leaf of an append-only Merkle log (RFC 6962 shapes); this returns the current size and root, signed by the active issuer key over { logSize, rootHash, signedAt }. Checkpoint heads somewhere the log cannot reach: any later head must prove consistency with yours.
Responses:
200: The signed tree head.429: Over the public log budget; retry later.
GET /v1/log/proof/{quitanzaId}
Inclusion proof for a quitanza (public).
No authentication, rate limited per client IP. Proves the quitanza is a leaf of the log: its leaf index, leaf hash (SHA-256 of 0x00 plus the document's canonical JSON), and the audit path to the current root. Verify against the signed tree head, offline.
- Path
quitanzaId: The quitanza to prove.
Responses:
200: The inclusion proof.404: No quitanza with this id in the log.429: Over the public log budget; retry later.
GET /v1/log/consistency/{firstSize}/{secondSize}
Consistency proof between two log sizes (public).
No authentication, rate limited per client IP. Proves the log at secondSize (omit for the current size: /v1/log/consistency/{firstSize}) extends the log at firstSize without rewriting anything behind it. Compare firstRoot against your checkpointed head, secondRoot against the current signed head.
- Path
firstSize: Your checkpointed tree size. - Path
secondSize: The newer tree size; path segment optional, defaults to the current size.
Responses:
200: The consistency proof.400: Sizes outside 1 <= first <= second <= current.429: Over the public log budget; retry later.
POST /v1/verify
Verify a posted quitanza (public).
No authentication. Post a full quitanza document; the response says whether the issuer signature verifies over the canonical body and whether the signing key is one of this deployment's published eras (with the era's validity window when it is). The on-chain anchor is echoed when present. Strictly rate limited per client IP, body capped at 32 KB. Trail intactness needs the server-held trail and lives at GET /v1/quitanzas/{id}/verify.
Responses:
200: The verification result.400: The body is not a quitanza document.413: Body over the 32 KB cap.429: Over the public verification budget; retry later.
GET /v1/escrows/{id}/quitanza
The quitanza that closed an escrow.
- Path
id: Escrow id (esc_…)
Responses:
200: The quitanza.404: not_found: the matter has not closed yet.
GET /v1/quitanzas/{id}
Fetch a quitanza by id.
- Path
id: Quitanza id (qtz_…)
Responses:
200: The quitanza.404: not_found
GET /v1/quitanzas/{id}/verify
Verify a quitanza against the live trail.
Checks the issuer signature over the canonical body (the signing key must be one of this deployment's published issuer keys), the integrity of the full evidence trail, and that the quitanza's trailHead matches the trail at its recorded length. The issuer field names the key that signed this quitanza, which after a rotation may differ from the key signing today. The same checks run offline with no Quitanza code. See the quitanza format spec.
- Path
id: Quitanza id (qtz_…)
Responses:
200: Verification verdicts.404: not_found
Webhooks
Durable signed event delivery: Ed25519 signature over the canonical JSON body, retries with backoff, inspectable dead-letter list. Every event is also replayable per escrow via GET /v1/escrows/{id}/events, derived from the evidence trail.
GET /v1/escrows/{id}/events
Replay every event the escrow emitted.
Every event, in order, exactly as webhooks received it: derived from the hash-chained evidence trail, so a replay can never disagree with the tamper-evident record. Use this to recover anything a webhook endpoint missed, or instead of webhooks entirely.
- Path
id: Escrow id (esc_…)
Responses:
200: The ordered event history.404: not_found
POST /v1/webhooks
Register a webhook endpoint.
Every engine event is POSTed to each registered URL as canonical JSON, signed with the announced key: x-quitanza-signature carries the Ed25519 signature, x-quitanza-key-id the signing public key. Failures retry with backoff; exhausted deliveries land on the dead-letter list.
Responses:
201: Registered. Pin signingKey to verify deliveries.400: bad_request: url is required.
GET /v1/webhooks
List registered webhooks and the signing key.
Responses:
200: Registered URLs and the signing public key.
GET /v1/webhooks/dead-letters
Deliveries that exhausted every retry.
Responses:
200: Dead letters, oldest first.
X402
The x402 payment handshake settled through escrow (simulated rails).
GET /v1/x402/demo
A paid resource settled through escrow (x402, simulated rails).
Without an X-PAYMENT header: 402 with PaymentRequirements (scheme quitanza-sandbox, network quitanza-local). With a valid signed payment header: runs the full escrow lifecycle for the request and returns the resource plus an X-PAYMENT-RESPONSE header naming the quitanza that proves the matter closed.
- Header
X-PAYMENT: Base64-encoded JSON payment payload built by @quitanza/x402 createPaymentHeader().
Responses:
200: The resource; X-PAYMENT-RESPONSE names the escrow and quitanza.402: Payment required: x402 PaymentRequirements offers.
Meta
Service metadata.
GET /health
Service health, version, uptime and issuer key.
Responses:
200: Service is up.
GET /.well-known/quitanza-issuer.json
The issuer keys this deployment signs quitanzas with.
Public, no authentication. Serves every Ed25519 issuer key era as { keys: [{ keyId, alg, publicKey, validFrom, validUntil? }] }, oldest first; the last key signs today and rotated-out keys carry validUntil. A quitanza verifies against a domain when its signing key appears here and the signature checks out offline, so proofs signed before a rotation keep verifying by keyId.
Responses:
200: The issuer key set.
POST /v1/admin/issuer/rotate
Rotate the issuer key (admin).
Generates a fresh Ed25519 issuer keypair and signs all future quitanzas with it. The outgoing key receives a validUntil stamp and stays in the well-known issuer document, so quitanzas issued before the rotation keep verifying by keyId. Root key only; refused on open sandboxes with no key configured.
Responses:
201: Rotation applied; the new issuer key set.401: Missing or wrong bearer key.403: This deployment has no API key configured; rotation is refused.
POST /v1/admin/keys
Create a bearer key (admin).
Mints a new bearer key for this deployment. The response is the only place the secret ever appears: the server stores its sha256 and nothing else. An optional rateLimitMax gives the key its own request budget per window. Root key only; refused on open sandboxes.
Responses:
201: The new key. Store the secret now: it is never shown again.401: Missing or wrong bearer key.403: Not the root key, or no API key configured.
GET /v1/admin/keys
List bearer keys (admin).
Every created key: id, label, createdAt, revokedAt when revoked, and any per-key rate limit. Secrets and hashes are never returned. Root key only.
Responses:
200: The key list.401: Missing or wrong bearer key.403: Not the root key, or no API key configured.
DELETE /v1/admin/keys/{keyId}
Revoke a bearer key (admin).
Stamps the key revoked, effective immediately; the id stays listed for audit. Idempotent. Root key only. The root key itself cannot be revoked here: it lives in the deployment environment.
- Path
keyId: The key id from creation or the key list.
Responses:
200: The key is revoked.401: Missing or wrong bearer key.403: Not the root key, or no API key configured.404: No key with this id.
GET /v1/admin/metrics
Operator metrics (admin).
One JSON page of operator sight: escrows by state, quitanzas issued, transparency-log size, webhook delivery counters, lifetime rate-limit rejections, API key counts, request-log line count and rotation state, uptime and version. Root key only.
Responses:
200: The metrics document.401: Missing or wrong bearer key.403: Not the root key, or no API key configured.
GET /v1/admin/rate
Live rate-limit counters (admin).
The current window's request counters by budget unit: authenticated traffic counts per key (key:root, key:
Responses:
200: Counters for the current window.401: Missing or wrong bearer key.403: This deployment has no API key configured.
GET /v1/openapi.json
This document.
The OpenAPI 3.1 description of every route, including the error model.
Responses:
200: The OpenAPI 3.1 document.
Error model
Every error is { "error": { "code", "message" } }. Codes: bad_request (400), payment_invalid (402), mandate_violation and unauthorized_ruling (403), not_found (404), invalid_state (409, illegal transition), unprocessable (422).