SplitPact v4 HTTP API

Reference for building clients, agents, and the splitpact-mcp-server on top of the dashboard backend. All endpoints are under https://<host>/api/ — in dev http://localhost:3001/api/.

Contents


Auth

Two actor kinds reach the API:

  1. Cookie session — iron-session via SIWS (splitpact_session cookie, 7-day TTL). Full access to every endpoint below.
  2. API key — bearer token minted from the dashboard at /settings/api-keys. Sent as Authorization: Bearer sppact_…. Draft- scoped: can create proposals, read and patch drafts, and list drafts. Everything else (sign, finalize, collaborators, partnerships, any row where status != 'draft' or onchainPactAddress != null) returns 403.
  1. POST /api/auth/nonce { wallet }{ nonce, expiresAt }
  2. Client constructs the SIWS message with that nonce, signs it
  3. POST /api/auth/verify { pubkey, signature, message } → sets the splitpact_session cookie, returns { ok: true, wallet, expiresAt }
  4. Subsequent requests send the cookie automatically (browser).

Rate limit: max 10 nonces per 60 s per client IP.

API keys (agents)

Mint at https://<host>/settings/api-keys after signing in. Key format is sppact_<32 base58 chars>. Store only the plaintext returned once on creation — the server stores SHA-256 hash.

Management endpoints (cookie session only):

  • POST /api/auth/api-keys { label }{ id, label, keyPrefix, createdAt, key }
  • GET /api/auth/api-keys{ keys: [{ id, label, keyPrefix, createdAt, lastUsedAt, revokedAt, expiresAt }] }
  • DELETE /api/auth/api-keys/:id{ ok: true }

Usage on proposal endpoints:

Authorization: Bearer sppact_3mP9qR7xK2wN5vJ8hL4fY6sT1aE0cD

Endpoint × actor-kind permissions

EndpointSessionAPI key
POST /api/proposal (business)
POST /api/proposal (partnership)403 apikey_business_only
GET /api/proposal/:id✓ (drafts only)
PATCH /api/proposal/:id✓ (drafts only)
GET /api/proposals✓ (returns drafts only)
POST /api/proposal/:id/collaborators403 apikey_forbidden_endpoint
DELETE /api/proposal/:id/collaborators403
POST /api/proposal/:id/sign403
POST /api/proposal/:id/finalize403
POST /api/mcp401 apikey_required_for_mcp

Remote MCP endpoint

POST /api/mcp is a stateless JSON MCP (streamable-HTTP) endpoint. Point any MCP client at it with a Bearer header:

claude mcp add --transport http splitpact https://<host>/api/mcp \
  --header "Authorization: Bearer sppact_..."

Same 5 tools as the stdio splitpact-mcp-server binary. See splitpact-mcp-server/README.md for tool details and examples.


Resources

ResourceNotes
ProposalA draft of a pact. Immutable on-chain after finalize.
PartnerA wallet required to sign a partnership proposal before finalize.
CollaboratorA wallet with viewer / editor access to a proposal draft.

Two proposal types:

  • business — single creator, can finalize solo. Uses controllerWallet. This is the type MCP v0 supports.
  • partnership — multiple partners, must all sign before finalize. Immutable controller (protocol-owned PDA).

POST /api/auth/nonce

Acquire a signing nonce.

Request

{ "wallet": "<base58 wallet>" }

Response 200

{
  "nonce": "eecbf13b7c29bbe6e9017e6c74ae3f93",
  "expiresAt": "2026-04-23T16:42:18.123Z"
}

Errors

  • 400 invalid_body — malformed wallet
  • 400 invalid_wallet — not a valid base58 pubkey
  • 429 rate_limited — exceeded 10 per minute
  • 500 server_error

POST /api/auth/verify

Verify a SIWS signature and install a session cookie.

Request

{
  "pubkey": "<base58 wallet>",
  "signature": "<base58 ed25519 signature>",
  "message": "<the SIWS-formatted message the user signed>"
}

The message must be the canonical SIWS format produced by buildSiwsMessage in app-v4/src/lib/auth/siws-message.ts. Fields validated: domain, chainId (must match SIWS_EXPECTED_* env), issued time within ±2 min of now, expiration not reached, nonce exists and not used.

Response 200 (Set-Cookie: splitpact_session=…)

{
  "ok": true,
  "wallet": "<base58>",
  "issuedAt": "...",
  "expiresAt": "..."
}

Errors — all 400 / 401 / 500 with error code: invalid_body, invalid_message_format, address_pubkey_mismatch, domain_mismatch, chain_mismatch, message_expired, nonce_unknown, nonce_already_used, nonce_expired, nonce_wallet_mismatch, invalid_signature.


GET /api/auth/me

Response 200

{
  "wallet": "<base58>",
  "issuedAt": "...",
  "expiresAt": "..."
}

Response 401 — no session or expired.


POST /api/auth/logout

Destroys the session cookie. Always 200.


POST /api/proposal

Create a new proposal (draft).

Request (minimal business)

{
  "proposalType": "business",
  "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
}

Request (full)

{
  "proposalType": "business",
  "tokenMint": "<USDC mint>",
  "controllerWallet": "<base58 — defaults to session wallet for business>",
  "maxNodes": 4,
  "maxEdges": 8,
  "payload": { "schemaVersion": 1, "canonical": { "nodes": [...], "edges": [...] }, "ui": {...} },
  "partnerWallets": [],
  "collaborators": [{ "wallet": "<base58>", "role": "editor" }]
}

Response 200

{
  "id": "aabab74e-6aa8-40b0-8fdd-49f9eac76940",
  "url": "/proposal/aabab74e-6aa8-40b0-8fdd-49f9eac76940"
}

Errors400 invalid_body (zod), 401 unauthenticated, 500 db_query_failed.


GET /api/proposal/:id

Fetch a proposal. Requires auth. Returns 403 if session wallet is neither creator, partner, nor collaborator.

Response 200

{
  "proposal": {
    "id": "...",
    "creatorWallet": "...",
    "proposalType": "business",
    "status": "draft",
    "tokenMint": "...",
    "controllerWallet": "...",
    "maxNodes": 16,
    "maxEdges": 48,
    "nonce": null,
    "payload": { ... },
    "payloadHash": "<hex>",
    "onchainPactAddress": null,
    "onchainTxSig": null,
    "createdAt": "...",
    "updatedAt": "...",
    "finalizedAt": null,
    "partners": [],
    "collaborators": []
  }
}

PATCH /api/proposal/:id

Partial update. Server merges patch over current row, recomputes payloadHash. If hash changed AND any partner has signedAt != null, those signatures are reset and signaturesInvalidated: true returned.

Request — subset of:

{
  "tokenMint": "...",
  "controllerWallet": "...",
  "maxNodes": 4,
  "maxEdges": 8,
  "payload": { ... partial ... }
}

Response 200

{ "proposal": { ... }, "signaturesInvalidated": false }

Errors400 invalid_body, 401, 403, 404 not_found, 409 immutable_after_finalize, 500.


POST /api/proposal/:id/collaborators

Add a collaborator. Only the creator can add.

Request

{ "wallet": "<base58>", "role": "viewer" }

Roles: "viewer" (read-only), "editor" (can PATCH draft).

Response 200 — returns updated proposal.


DELETE /api/proposal/:id/collaborators

Remove a collaborator. Only the creator can remove.

Request

{ "wallet": "<base58>" }

Response 200 — returns updated proposal.


POST /api/proposal/:id/sign

Partner-signs a partnership proposal. No-op on business proposals.

Request

{
  "signature": "<base58 ed25519 signature over the partnership approval message>"
}

The signature must be over the canonical partnership approval message (see app-v4/src/lib/proposal/approval-message.ts). Server verifies via tweetnacl.

Response 200 — returns updated proposal.


POST /api/proposal/:id/finalize

Marks the proposal as on-chain-finalized. Client provides the on-chain pact address and tx signature after the Solana transaction succeeds.

Request

{
  "onchainPactAddress": "<base58>",
  "onchainTxSig": "<base58 tx sig>",
  "nonce": "<u64 decimal string>"
}

Response 200 — sets status: "finalized", finalizedAt, and the on-chain fields. Further PATCHes are rejected.


GET /api/proposals

List all proposals the session wallet creates, partners on, or collaborates on. No pagination (caps at MAX_PROPOSALS per user, currently unbounded; add client-side filtering).

Response 200

{ "proposals": [ ... array of the same Proposal shape as GET /:id ... ] }

Data shapes

ProposalNode

{
  id: number;                  // 0 <= id <= MAX_NODES-1
  kind: "root" | "intermediate";
  label: string;               // ≤ 32 bytes UTF-8
  emergencyTarget: string | null;  // base58 wallet, optional
}

Exactly one root per proposal.

ProposalEdgeTarget

{ kind: "internal"; nodeId: number }
| { kind: "external"; wallet: string }  // base58

ProposalCondition (tagged union, 5 kinds)

{ kind: "afterInflow"; min: string }         // u64 decimal, fire when source.lifetimeInflow ≥ min
{ kind: "inflowRange"; min: string; max: string }  // u64, fire while in [min, max)
{ kind: "capOutflow"; max: string }          // u64, limit edge outflow to max lifetime
{ kind: "timeGate"; after: string; before: string }  // i64 unix seconds, fire in [after, before)
{ kind: "whenHoldingAtLeast"; min: string }  // u64, fire when source.holding ≥ min

All numeric params are decimal strings to preserve u64/i64 precision through JSON.

Condition validation:

  • inflowRange requires min < max
  • timeGate requires after < before
  • capOutflow requires max > 0

ProposalEdge

{
  id: number;                  // 0 <= id <= MAX_EDGES-1
  source: number;              // node id
  target: ProposalEdgeTarget;
  shareBps: number;            // 0..10000
  conditions: ProposalCondition[];  // max 4
}

On-chain semantics: within a single flush_node, edges are evaluated in edge.id ASC order. shareBps applies to source.holding at the moment of firing (not the original inflow). See simulator/src/engine.test.ts for canonical examples.

ProposalPayload

{
  schemaVersion: 1;
  canonical: {
    nodes: ProposalNode[];
    edges: ProposalEdge[];
  };
  ui: {
    nodePositions?: Record<string, { x: number; y: number }>;
    conditionPills?: Record<PillId, ConditionPill>;   // visual-only
    visualEdges?: Record<VisualEdgeId, VisualEdge>;   // visual-only
    // extra keys allowed (passthrough)
  };
}

canonical is hashed for the payload hash used in signatures. ui is not hashed — it's free to carry layout/visual state.

Condition pills (visual layer, business flow)

The editor optionally represents conditions as individual visual nodes ("pills"), but they compile to ProposalEdge.conditions[] before persisting. Agents can skip the visual layer entirely — when an imported payload has canonical.edges[].conditions but no conditionPills, the editor auto-synthesizes pills on load.


Limits and constants

Mirror app-v4/src/lib/proposal/constants.ts and simulator/src/constants.ts:

ConstantValueMeaning
MAX_NODES16Per proposal
MAX_EDGES48Per proposal
MAX_OUTGOING_PER_NODE8Per source node
MAX_CONDITIONS_PER_EDGE4Per edge
MAX_PROTOCOL_FEE_BPS500Hard cap, 5%
BPS_DENOMINATOR10000100%
NODE_LABEL_BYTES32UTF-8 bytes max
Current protocol fee50 bps (0.5%)Settable via update_protocol_config
Session TTL7 daysiron-session maxAge

Portable JSON format

Exported by the "Export" button in the business editor and accepted by the "Import" button. Structure:

{
  "kind": "splitpact.business.pact",
  "schemaVersion": 1,
  "exportedAt": "2026-04-23T10:00:00.000Z",
  "payload": { ... ProposalPayload ... }
}

Agents building pacts programmatically should emit this wrapper — the kind literal and schemaVersion are what the editor validates before importing.

Minimal agent-produced doc (ui empty; pills auto-synthesized on import):

{
  "kind": "splitpact.business.pact",
  "schemaVersion": 1,
  "payload": {
    "schemaVersion": 1,
    "canonical": {
      "nodes": [
        { "id": 0, "kind": "root", "label": "Revenue", "emergencyTarget": null }
      ],
      "edges": [
        {
          "id": 0,
          "source": 0,
          "target": { "kind": "external", "wallet": "7xKXtg2CW87dQrzajK1dSXjsYkxgf2d31uaEKM3YkcRn" },
          "shareBps": 10000,
          "conditions": []
        }
      ]
    },
    "ui": {}
  }
}

Worked example: 50/50 business pact

Agent goal: "create a business pact that splits 1 USDC deposit 50/50 between Alice and Bob, but Bob's share only fires after ≥ 1 USDC has been received."

As a POST /api/proposal body

{
  "proposalType": "business",
  "tokenMint": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  "payload": {
    "schemaVersion": 1,
    "canonical": {
      "nodes": [
        { "id": 0, "kind": "root", "label": "Revenue", "emergencyTarget": null }
      ],
      "edges": [
        {
          "id": 0,
          "source": 0,
          "target": { "kind": "external", "wallet": "ALiCE..." },
          "shareBps": 5000,
          "conditions": []
        },
        {
          "id": 1,
          "source": 0,
          "target": { "kind": "external", "wallet": "BoB..." },
          "shareBps": 10000,
          "conditions": [
            { "kind": "afterInflow", "min": "1000000" }
          ]
        }
      ]
    },
    "ui": {}
  }
}

Key points:

  • Edges are iterated by id ASC. Edge 0 (Alice, 50%) drains half of the current Root holding. Edge 1 (Bob, 100% of what's left) drains the remaining 500 000 micro-USDC — so terminal distribution is 50/50 after the inflow condition clears.
  • afterInflow.min = "1000000" is micro-USDC (6 decimals). 1 USDC = 1 000 000 base units.
  • MAX_OUTGOING_PER_NODE = 8, so Root can fan out up to 8 edges.

See simulator/src/engine.test.ts under describe("plain split") for matching simulator expectations.


← All docs