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
- Resources
- POST /api/auth/nonce
- POST /api/auth/verify
- GET /api/auth/me
- POST /api/auth/logout
- POST /api/proposal
- GET /api/proposal/:id
- PATCH /api/proposal/:id
- POST /api/proposal/:id/collaborators
- DELETE /api/proposal/:id/collaborators
- POST /api/proposal/:id/sign
- POST /api/proposal/:id/finalize
- GET /api/proposals
- Data shapes
- Limits and constants
- Portable JSON format
- Worked example: 50/50 business pact
Auth
Two actor kinds reach the API:
- Cookie session — iron-session via SIWS (
splitpact_sessioncookie, 7-day TTL). Full access to every endpoint below. - API key — bearer token minted from the dashboard at
/settings/api-keys. Sent asAuthorization: Bearer sppact_…. Draft- scoped: can create proposals, read and patch drafts, and list drafts. Everything else (sign, finalize, collaborators, partnerships, any row wherestatus != 'draft'oronchainPactAddress != null) returns 403.
Cookie session flow (SIWS)
POST /api/auth/nonce { wallet }→{ nonce, expiresAt }- Client constructs the SIWS message with that nonce, signs it
POST /api/auth/verify { pubkey, signature, message }→ sets thesplitpact_sessioncookie, returns{ ok: true, wallet, expiresAt }- 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
| Endpoint | Session | API 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/collaborators | ✓ | 403 apikey_forbidden_endpoint |
DELETE /api/proposal/:id/collaborators | ✓ | 403 |
POST /api/proposal/:id/sign | ✓ | 403 |
POST /api/proposal/:id/finalize | ✓ | 403 |
POST /api/mcp | 401 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
| Resource | Notes |
|---|---|
| Proposal | A draft of a pact. Immutable on-chain after finalize. |
| Partner | A wallet required to sign a partnership proposal before finalize. |
| Collaborator | A wallet with viewer / editor access to a proposal draft. |
Two proposal types:
business— single creator, can finalize solo. UsescontrollerWallet. This is the type MCP v0 supports.partnership— multiple partners, must all sign before finalize. Immutablecontroller(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 wallet400 invalid_wallet— not a valid base58 pubkey429 rate_limited— exceeded 10 per minute500 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"
}
Errors — 400 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 }
Errors — 400 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:
inflowRangerequiresmin < maxtimeGaterequiresafter < beforecapOutflowrequiresmax > 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:
| Constant | Value | Meaning |
|---|---|---|
MAX_NODES | 16 | Per proposal |
MAX_EDGES | 48 | Per proposal |
MAX_OUTGOING_PER_NODE | 8 | Per source node |
MAX_CONDITIONS_PER_EDGE | 4 | Per edge |
MAX_PROTOCOL_FEE_BPS | 500 | Hard cap, 5% |
BPS_DENOMINATOR | 10000 | 100% |
NODE_LABEL_BYTES | 32 | UTF-8 bytes max |
| Current protocol fee | 50 bps (0.5%) | Settable via update_protocol_config |
| Session TTL | 7 days | iron-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
idASC. 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.