Skip to content
Rulebook 0.4.0 · effective Standards CD-0 · OS-1 · OS-2 · OS-3
Developers and agents

Docs

The Commitment API is the layer, and every surface Recourse ships is a client of it. Five calls run a commitment from nothing to a signed record either party can verify without trusting us.

Everything on this page is generated from the shipped SDK, the endpoints the API serves, and the Rulebook. If a sample here is wrong, the build is wrong.

Lifecycle calls5
Commitment states18
Error codes25
Rulebook0.4.0

This document also answers Accept: text/markdown and Accept: application/json at this URL, and is addressable directly at /repr/md/docs and /repr/json/docs.

Clients
#ways-in

Three ways in

An agent, a developer, and an MCP client reach the same API. Nothing below is different per client; only the first fetch is.

An agent

Fetch two files.

/llms.txt says what Recourse is and where everything else is. /llms-full.txt is this whole page, the standards, and the reason codes in one plain-text document.

A developer

Install the client.

The typed SDK maps every problem+json document to one error with a stable code, and sends an idempotency key on every call that moves money.

pnpm add @recourse/sdk
An MCP client

Run the server.

@recourse/mcp exposes the lifecycle as tools over stdio, and holds the API key in its own environment rather than in a prompt. Configuration below.


Discovery

An agent that has never heard of Recourse can learn the whole protocol from five documents. The website serves the two text files; the API serves the two well-known documents and its own description.

Discovery documents5 documents · no credential
DocumentOriginWhat
/llms.txtrecourse-mu.vercel.appThis site in one short document: what Recourse is and where everything else is.
/llms-full.txtrecourse-mu.vercel.appThe whole protocol in one fetch: steps, states, standards, reason codes, errors, endpoints.
/.well-known/recourse.jsonapi.recourse.devMachine discovery: the API base, the scopes, the rulebook version, and the standards in force.
/.well-known/recourse-keys.jsonapi.recourse.devThe Ed25519 public keys events and record snapshots are signed with. Verify without asking Recourse.
/openapi.jsonapi.recourse.devOpenAPI 3.1, generated from the live route table. Every client is generated from it.
Fetch them
curl -sS https://recourse-mu.vercel.app/llms.txt
curl -sS https://recourse-mu.vercel.app/llms-full.txt
curl -sS https://api.recourse.dev/.well-known/recourse.json
curl -sS https://api.recourse.dev/openapi.json

The keys document publishes the Ed25519 public keys the event log and the record snapshots are signed with. It is served by the API because the service holding the keys is the one that should answer for them. Open it.


Step 1
#keys

Get a key

Parties are organizations or agents, symmetrically. An agent is a party with keys, a record, and obligations — the same record an organization gets, and the same endpoints. There is no separate agent integration to migrate to.

Credentials

rk_test_…

Organization key, sandbox. No money moves.

rk_live_…

Organization key, live.

ak_…

Agent key, bound to an agent party with a principal organization. Same endpoints, same scopes.

Send it as Authorization: Bearer <key>. The token is returned once, at creation, and never again: only its SHA-256 is stored.

Scopes

commitments:write

Create, propose, accept, fund, and cancel commitments.

outcomes:write

Submit outcomes and evidence against an active commitment.

disputes:write

Open a dispute, post a bond, and file evidence.

records:read

Read a party's record and export an audit bundle.

A key without the scope gets 403 insufficient_scope — never a partial result.

Mint one

Onboarding calls2 calls
CallActorWhat
POST /v1/partiesAny credentialCreate a party — an organization or an agent. Both are the same record with keys, a record, and obligations. client.request({ method: "POST", path: "/v1/parties", body })
POST /v1/parties/{id}/keysThe party's principalMint a scoped key for that party. The token is returned once and never again. client.request({ method: "POST", path: `/v1/parties/${id}/keys`, body })
Check it
curl -sS https://api.recourse.dev/v1/whoami \
  -H "Authorization: Bearer $RECOURSE_API_KEY"

Step 2
#terms

Write the terms

Terms are a typed JSON document. Recourse canonicalizes them (RFC 8785), takes the SHA-256, and the buyer signs that hash. Neither side can change what was agreed without changing the hash. Money is integer cents, USD, never a float.

Terms
{
  "kind": "outcome_stream",
  "standard": {
    "id": "OS-1",
    "version": "0.4.0"
  },
  "unit_price": {
    "amount_cents": 175,
    "currency": "usd"
  },
  "budget_per_period": {
    "amount_cents": 500000,
    "currency": "usd"
  },
  "period": "monthly",
  "term_periods": 1,
  "evidence_source": {
    "type": "connector",
    "connector": "zendesk"
  },
  "dispute": {
    "buyer_review_window_hours": 120,
    "statement_dispute_window_hours": 120,
    "evidence_window_hours": 48,
    "appeal_window_hours": 72,
    "dispute_bond": {
      "pct": 2,
      "min_cents": 2500,
      "max_cents": 250000
    },
    "appeal_bond": {
      "pct": 5,
      "min_cents": 10000,
      "max_cents": 1000000
    },
    "path": [
      "rulebook",
      "panel",
      "aaa"
    ],
    "governing_law": "US-DE"
  },
  "rulebook_version": "0.4.0"
}
Kindoutcome_stream
StandardOS-1 0.4.0
Rulebook0.4.0
Terms hashsha256:aef01ff030c41a3d18633bf4493f305d823be7624573c9b5b97465974ce2dfdd

The hash above is real. Canonicalize the document beside it, take the SHA-256, and you get the same 64 characters.

The other kind is deliverable: one deliverable, acceptance criteria the vendor writes, a deadline, and a price held up front. Recourse rejects a criterion that is not testable as written. Read the standards.


Step 3
#calls

The five calls

These five run a commitment from nothing to a signed, verifiable record. No other call is required. A POST that creates or moves money takes an Idempotency-Key; repeating it replays the first answer instead of creating a second commitment or a second hold.

The lifecycle calls5 calls · @recourse/sdk
CallActorWhat
POST /v1/commitmentsEither party · Idempotency-KeyCreate the draft and record its terms. Answers with the commitment id and the terms hash. client.commitments.create(body)
POST /v1/commitments/{id}/proposeVendorPublish the terms. Answers with the hosted contract link and the published terms hash. client.commitments.propose(id)
POST /v1/commitments/{id}/acceptBuyerSign the terms hash. A hash that is not the current one is refused, not recorded. client.commitments.accept(id, { terms_hash })
POST /v1/commitments/{id}/fundBuyer · Idempotency-KeyInstruct the hold on the rail and activate once it covers the amount the terms require. client.commitments.fund(id, { amount })
GET /v1/commitments/{id}/eventsEither partyRead the append-only, hash-chained log with its chain verification and the log public key. client.commitments.events(id)
Two clients
import { createClient } from "@recourse/sdk";

// The vendor writes and publishes the terms.
const vendor = createClient({
  baseUrl: "https://api.recourse.dev",
  apiKey: process.env.VENDOR_API_KEY,
});

// The buyer signs the terms hash and funds the hold. A different key, because
// the API refuses a command from the wrong side of the commitment.
const buyer = createClient({
  baseUrl: "https://api.recourse.dev",
  apiKey: process.env.BUYER_API_KEY,
});

Two clients, because a commitment has two sides and neither may act for the other. Only the vendor proposes; only the buyer accepts and funds. A command from the wrong side is refused with 403 wrong_party.

Rail

The manual rail ships first: it records the instruction and its state, and money moves between the parties on their own arrangements. Stripe, Circle, and x402 adapters follow. A commitment reports the rail it is on.

The lifecycle
// 1 — Create. The terms above are canonicalized (RFC 8785) and hashed. This
//     call creates a commitment, so the SDK sends an Idempotency-Key: a retry
//     returns the same commitment instead of a second one.
const commitment = await vendor.commitments.create({
  kind: "outcome_stream",
  vendor_party_id: vendorPartyId,
  buyer_party_id: buyerPartyId,
  terms,
});

// 2 — Propose. The vendor publishes the terms and gets the link the buyer opens.
const { hosted_url, terms_hash } = await vendor.commitments.propose(commitment.id);

// 3 — Accept. The buyer signs that exact hash. A hash that is not the current
//     one is refused with terms_hash_mismatch, not recorded.
await buyer.commitments.accept(commitment.id, { terms_hash });

// 4 — Fund. The hold is instructed on the rail. The commitment activates once
//     the hold covers the amount the terms require.
const funded = await buyer.commitments.fund(commitment.id, {
  amount: { amount_cents: 500_000, currency: "usd" },
});
funded.state; // "active"

// 5 — Read the record. The chain check and the log public key come with it, so
//     the caller can verify the stream without trusting this response.
const stream = await vendor.commitments.events(commitment.id);
stream.verification; // { ok: true }
stream.log_public_key; // raw Ed25519 public key, base64

Over plain HTTP

curl -sS https://api.recourse.dev/v1/commitments \
  -H "Authorization: Bearer $RECOURSE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "kind": "outcome_stream",
    "vendor_party_id": "pty_01K3M8QW4YB7N0R6TVXZ2FJ5AD",
    "buyer_party_id": "pty_01K3M9C2H8D4S1Y7GNRT6VKQ0B",
    "terms": { "...": "the terms document above" }
  }'

Everything else the API answers

Supporting calls7 calls
CallActorWhat
GET /v1/whoamiAny credentialThe organization, agent party, scopes, and key id this credential resolves to. client.request({ method: "GET", path: "/v1/whoami" })
GET /v1/commitmentsEither partyThe commitments this credential is a party to, newest first, cursor-paginated. client.commitments.list({ role, state })
GET /v1/commitments/{id}Either partyOne commitment: state, terms hash, timestamps, and the hold. client.commitments.get(id)
POST /v1/commitments/{id}/cancelEither partyCancel while no hold is funded. After funding, the commitment settles through the protocol instead. client.commitments.cancel(id)
GET /v1/public/contracts/{slug}No credentialThe hosted contract a buyer opens: the commitment, the full terms, and both parties. client.contracts.get(slug)
GET /v1/standardsAny credentialThe Outcome Standards this deployment serves, with their ids, versions, and hashes. client.request({ method: "GET", path: "/v1/standards" })
GET /v1/reason-codesAny credentialEvery reason code a ruling may cite, with the rule text, for one rulebook version. client.request({ method: "GET", path: "/v1/reason-codes" })

The machine
#states

Commitment states

Every state change is an immutable, signed event, and the tables are projections of the log. A command that is not legal from the current state is refused with 409 invalid_transition; it is never silently ignored.

Commitment states18 states · @recourse/core
StateShown asWhat it means
draftdraftTerms are on file. Nothing is published and no money has moved.
proposedproposedThe terms are published at the hosted contract link. The buyer has not signed.
acceptedacceptedThe buyer signed the terms hash. No hold exists yet.
fundedfundedA hold exists on the rail. It does not yet cover the amount the terms require.
activeactiveThe hold covers the required amount. Outcomes count from here.
verifyingverifyingEvidence is being checked against the standard: deterministic criteria, then the rubric.
reviewreviewA rubric criterion could not be settled with confidence. A person decides, within 24 hours.
verifiedverifiedThe unit met the standard and is releasable.
rejectedrejectedA required criterion failed. The unit is not paid, and a reason code says which criterion.
disputeddisputedA party disputed inside the window and posted a bond. The evidence window is open.
ruledruledRecourse applied the Rulebook and issued a ruling citing a rule id and a reason code.
appealedappealedThe losing party appealed to a panel and posted the appeal bond.
releasedreleasedFunds moved to the vendor on the rail. The platform fee is taken here and nowhere else.
refundedrefundedFunds returned to the buyer on the rail.
sealedsealedThe commitment is closed and its summary is signed. Nothing further is written.
expiredexpiredThe terms were not accepted before they lapsed. No money moved.
cancelledcancelledA party cancelled before any hold was funded.
amendingamendingNew terms are being agreed. The terms in force still govern until the new hash is signed.

The chip is the same mapping every Recourse surface uses, so a state is never one colour on a dashboard and another on a record.


Failure
#errors

Errors say what failed and what to do

Every failure is RFC 9457 problem+json. Branch on code: the sentence may be reworded, the code will not. detail says what failed, next names the call that resolves it, and request_id is the one string to quote to support.

A problem document
{
  "type": "https://recourse.dev/problems/invalid_transition",
  "title": "Invalid transition",
  "status": 409,
  "code": "invalid_transition",
  "detail": "command \"fund\" is not allowed from state \"draft\": the terms are not signed",
  "next": "GET /v1/commitments/{id} for the current state, then send the command that state allows.",
  "request_id": "req_4ed73d80-cf4e-4d20-834f-2df272041685"
}
Machine error codes25 codes · problem+json
CodeStatusWhat failed, and what to do
invalid_request400The body or the query did not match the schema. Next: Fix the fields named in detail and send the request again. The schemas are in /openapi.json.
unauthorized401No credential, or one that does not resolve. Next: Send Authorization: Bearer <api key>. An organization mints an agent key with POST /v1/parties/{id}/keys.
insufficient_scope403The key is valid and does not hold the scope this route needs. Next: Mint a key that holds the scope named in detail with POST /v1/parties/{id}/keys, then repeat the request.
wrong_party403The credential is a party to this commitment, but the wrong side of it. Next: Call as the party named in detail. GET /v1/whoami reports the party this credential acts as.
not_found404No such commitment, or none this credential may read. Next: Check the id. GET /v1/commitments lists the commitments this party is a party to.
unknown_party404The party named in the request does not exist. Next: Create the party with POST /v1/parties, then use the id it returns.
invalid_transition409The command is not legal from the state the commitment is in. Next: GET /v1/commitments/{id} for the current state, then send the command that state allows.
concurrent_modification409The commitment moved while this command was being applied. Next: GET /v1/commitments/{id} again and resend the command against the state you read.
terms_hash_mismatch409The hash being signed is not the current terms hash. Next: GET /v1/commitments/{id}, take current_terms_hash, and sign that hash.
terms_missing409The commitment has no valid terms on file. Next: Create the commitment again with its terms. POST /v1/commitments records them.
signature_invalid400The signature does not verify against the party key over the terms hash. Next: Sign the utf8 bytes of the lowercase hex terms hash with the party key and send the signature base64.
idempotency_key_required400A call that creates or moves money arrived without an Idempotency-Key. Next: Send an Idempotency-Key header with a value unique to this request, then repeat it verbatim to retry.
idempotency_key_reused409The same key arrived with a different body. Next: Send a new Idempotency-Key, or resend the identical body under the same key.
rail_unsupported400The rail named in the request is not the rail this commitment is on. Next: Fund on the rail the commitment names. GET /.well-known/recourse.json lists the rails this deployment carries.
unknown_standard400The terms cite an Outcome Standard this deployment does not serve. Next: GET /v1/standards for the standards this deployment serves, with their ids, versions, and hashes.
unknown_rulebook_version400The terms cite a rulebook version that is not published. Next: GET /.well-known/recourse.json for the rulebook version in effect and its hash.
unknown_reason_code400The reason code asked for is not in that rulebook version. Next: GET /v1/reason-codes for every reason code in the rulebook version that produced the ruling.
rate_limited429Too many requests in the window. Next: Wait for the window to pass and repeat the request unchanged.
method_not_allowed405The path exists and does not answer this method. Next: Use the method /openapi.json declares for this path.
unsupported_media_type415The body was not JSON. Next: Send application/json.
not_ready503The service is up and its database is not answering. Next: Retry with backoff. GET /ready reports when the service answers again.
internal_error500The request failed for a reason the API did not expect. Next: Retry with backoff. If it persists, quote request_id — the failure is recorded against it.
connection_failedno replyThe SDK never reached the API: DNS, TCP, TLS, or offline. Nothing was recorded. Next: Retry with backoff. Send the same Idempotency-Key on a money call.
timeoutno replyThe SDK aborted the request before an answer arrived. The call may already have landed. Next: Retry with the same Idempotency-Key, then GET /v1/commitments/{id} to confirm the state.
invalid_responseno replyThe API answered with something other than the document the method asked for. Next: Do not retry blindly. GET /v1/commitments/{id} and branch on the state you read.
Branching on a code
import { RecourseError } from "@recourse/sdk";

try {
  await buyer.commitments.accept(id, { terms_hash });
} catch (err) {
  if (!(err instanceof RecourseError)) throw err;

  switch (err.code) {
    case "terms_hash_mismatch": {
      // The terms moved. Read the current hash and sign that one.
      const current = await buyer.commitments.get(id);
      if (current.current_terms_hash !== null) {
        await buyer.commitments.accept(id, { terms_hash: current.current_terms_hash });
      }
      break;
    }
    case "invalid_transition": {
      // The command is not legal from this state. Read the state, then act.
      const current = await buyer.commitments.get(id);
      handle(current.state);
      break;
    }
    case "wrong_party":
    case "insufficient_scope":
      // Nothing to retry: the credential is wrong, not the request.
      throw err;
    default:
      if (err.isRetryable) await retryWithBackoff();
      else throw err;
  }
}

Trust nothing
#verify

Verify a commitment yourself

Nothing on a record needs taking on faith. Every claim is checkable from published material with a SHA-256 and an Ed25519 verify, and the check needs no Recourse dependency.

01

Recompute the terms hash.

terms_hash = sha256(JCS(terms)), lowercase hex, rendered on the wire as sha256:<hex>. Canonicalize the terms document with RFC 8785 and hash it. It matches, or the terms you are holding are not the terms that were signed.

02

Recompute the standard hash.

Every Outcome Standard is a published JSON file. sha256(JCS(file)) is the hash a commitment cites. A standard that changed would change its hash, and the commitment cites the version it was accepted under.

03

Walk the event chain.

hash = sha256(JCS({stream_id, seq, type, payload, occurred_at, prev_hash})), where prev_hash is the bare hex of the event before it and null at seq 1. Strip the sha256: prefix the API renders before you hash. A missing or altered event breaks the chain at that seq.

04

Check the log signature.

Each event carries an Ed25519 signature, base64, over the utf8 bytes of its bare lowercase hex hash. The raw public key is on the response as log_public_key and in the published keys document. Verify against the published key, not the one on the response, if you want the stronger check.

The whole check
import { createHash, createPublicKey, verify } from "node:crypto";
import { canonicalize } from "@recourse/crypto"; // or any RFC 8785 implementation

const sha256 = (value: unknown): string =>
  createHash("sha256").update(canonicalize(value), "utf8").digest("hex");

// 1 — The terms hash the buyer signed.
sha256(terms) === commitment.terms_hash?.replace(/^sha256:/, "");

// 2 — The chain. On the wire a hash reads "sha256:<hex>"; the chain is hashed
//     over the bare hex, and prev_hash is null at seq 1.
const stream = await client.commitments.events(commitment.id);
const publicKey = createPublicKey({
  key: Buffer.concat([
    Buffer.from("302a300506032b6570032100", "hex"), // Ed25519 SPKI prefix
    Buffer.from(stream.log_public_key, "base64"),
  ]),
  format: "der",
  type: "spki",
});

let prev: string | null = null;
for (const event of stream.data) {
  const hash = sha256({
    stream_id: event.stream_id,
    seq: event.seq,
    type: event.type,
    payload: event.payload,
    occurred_at: event.occurred_at,
    prev_hash: prev,
  });
  if (hash !== event.hash.replace(/^sha256:/, "")) {
    throw new Error(`the chain breaks at seq ${event.seq}`);
  }

  // 3 — The log signature: Ed25519 over the utf8 bytes of the hex hash.
  const signed = verify(
    null,
    Buffer.from(hash, "utf8"),
    publicKey,
    Buffer.from(event.signature, "base64"),
  );
  if (!signed) throw new Error(`the signature fails at seq ${event.seq}`);

  prev = hash;
}

For agents
#mcp

The MCP server

An agent whose client speaks MCP can use @recourse/mcp instead of HTTP. It runs over stdio and reads the API key from its own environment, so the key never enters a prompt or a tool argument. The package is not published to a registry yet; run it from a clone of the repository.

MCP client configuration
{
  "mcpServers": {
    "recourse": {
      "command": "pnpm",
      "args": ["--filter", "@recourse/mcp", "dev"],
      "env": {
        "RECOURSE_API_URL": "https://api.recourse.dev",
        "RECOURSE_API_KEY": "rk_live_…"
      }
    }
  }
}
Package@recourse/mcp
Binaryrecourse-mcp
Transportstdio
RECOURSE_API_URL

The API base, e.g. https://api.recourse.dev.

RECOURSE_API_KEY

An org key (rk_live_…, rk_test_…) or an agent key (ak_…).


Rules as data
#rules

The rules are data

The Rulebook and the Outcome Standards ship as versioned JSON. Every ruling cites a rule id and the rulebook version in force when the terms were accepted. Nothing in this documentation restates them; it links to them.

Rulebook 0.4.0

Definitions, procedure, verification order, appeals, records, and amendments, with 24 reason codes a ruling may cite.

Read the Rulebook

Outcome Standards

What a unit is, what evidence proves it, and what rejects it — with the hash of each published file.

Read the standards

The record

What the layer has settled, in aggregate. No party is named and there is no sample data.

Open the ledger