Skip to main content

HTTP API

toda exposes two HTTP services against the same database and the same crypto primitives.

ServicePortAuthClients
Core API (apps/api)3001SIWS JWT · API keys (toda_…)SDK, dashboard
Extension API (apps/extension-api)3002SIWS session tokensChrome extension, capability grants

Both accept Authorization: Bearer <token> and both return JSON unless noted. A memory written through either surface lands in the same log under the same ownerPubkey and produces the same leaf.


Core API

Health

GET /health → { "ok": true, "service": "toda-api" }

Unauthenticated.

Authentication

POST /auth/challenge

// request
{ "publicKey": "7xKX…" }

// 200
{
"message": "toda.io wants you to sign in with your Solana account:\n7xKX…\n\n…",
"nonce": "4bTxK9…",
"issuedAt": "2026-07-08T12:00:00.000Z",
"expiresAt": "2026-07-08T12:05:00.000Z"
}

Nonce TTL is 5 minutes. Expired nonces are pruned opportunistically.

POST /auth/verify

// request
{ "publicKey": "7xKX…", "message": "<exact message>", "signature": "<base58 ed25519>" }

// 200
{ "token": "eyJhbGci…", "expiresAt": "2026-07-08T13:00:00.000Z", "publicKey": "7xKX…" }
StatusCause
400Malformed request, or message.address !== publicKey
401Unknown or already-consumed nonce · challenge expired · message mismatch · bad signature

The nonce is consumed atomically. Session JWTs live 1 hour. See authentication.

Memories

All memory routes require authentication and are scoped to the caller's userId in SQL.

POST /memories

// request
{ "content": "The user always deploys on Fridays.", "metadata": { "kind": "preference" } }

// 200
{ "id": "6c1f…", "leafHash": "9ab3…", "leafIndex": 41 }

Returns 400 when content is empty or metadata is not an object.

GET /memories

Returns every memory for the caller, decrypted.

[
{
"id": "6c1f…",
"content": "The user always deploys on Fridays.",
"metadata": { "kind": "preference" },
"leafHash": "9ab3…",
"leafIndex": 41,
"batchId": "b2e9…",
"anchored": true,
"createdAt": "2026-07-08T12:00:00.000Z"
}
]

GET /memories/:id

Returns one memory. Missing and unauthorized IDs both return 404.

POST /memories/search

// request
{ "query": "deployment" }

// 200 - same shape as GET /memories

Case-insensitive substring match over decrypted bodies. An empty or absent query returns everything. Matching happens in the API process after decryption; see recall.

GET /memories/:id/proof

{
"proof": {
"leaf": "9ab3…",
"leafIndex": 41,
"siblings": ["c40f…", "1e77…"],
"directions": [true, false]
},
"batchRoot": "7de2…",
"txSignature": "5Kx…",
"onchainRoot": "7de2…"
}

txSignature is null for a memory not yet anchored. onchainRoot is null if the user's commitment account does not exist yet. directions[i] === true means the sibling at level i is on the right.

API keys

POST /keys

// request
{ "label": "trading-agent-prod" }

// 200. The raw key is returned once.
{
"id": "…",
"key": "toda_9f2a4c7e1b6d8a03f5e2c1b9d7a4e6f8c3b1d9e7a5f2c4b6",
"prefix": "toda_9f2a4c7",
"label": "trading-agent-prod",
"createdAt": "2026-07-08T12:00:00.000Z",
"lastUsedAt": null,
"revokedAt": null
}

GET /keys

Lists keys without the secret - id, prefix, label, lastUsedAt, revokedAt, createdAt.

DELETE /keys/:id

{ "ok": true }

Revocation takes effect on the next request.

Staking

Dormant until a stake mint and staking program are configured. See Staking.

GET /staking/status

{
"live": false,
"mint": null,
"decimals": 0,
"staked": "0",
"mature": "0",
"cooling": "0",
"nextMatureAt": null,
"lots": [],
"vault": null,
"walletBalance": "0",
"minPartialUnstake": "0",
"maturityMs": 86400000
}

When live, staked is the on-chain vault balance, mature the portion past the maturity window, and lots the per-deposit maturity clocks. All token amounts are base-unit strings, not numbers - a u64 does not fit in a JSON number.

POST /staking/stake · POST /staking/unstake

// request
{ "amount": "1000000" } // base units, decimal string

// 200. The wallet must sign and send the transaction.
{ "transaction": "<base64>", "blockhash": "…", "lastValidBlockHeight": 291043912 }
StatusCause
400Non-integer amount, zero, out of u64 range; unstake exceeding the staked balance; partial unstake below MIN_PARTIAL_UNSTAKE
503Staking is not live yet - no mint configured
503On unstake, the vault balance could not be read. toda refuses rather than guessing

The server does not sign staking transactions. Full withdrawal is allowed regardless of MIN_PARTIAL_UNSTAKE.


Extension API

Serves the Chrome extension and both capability grants. CORS accepts any chrome-extension:// origin plus a configured allowlist.

Authentication

POST /auth/challenge and POST /auth/verify mirror the core API, with two differences: the signature may be supplied as a base58 string or a 64-element byte array (Phantom's signMessage returns bytes), and the issued token is stateful - a random token whose SHA-256 is stored, supporting real revocation.

GET /auth/me → { "userId": "…", "publicKey": "7xKX…" }
POST /auth/logout → { "ok": true }

Connect page

GET /connect?callback=chrome-extension://<id>/callback.html

Serves a self-contained Phantom login page. It performs the full SIWS handshake in the browser, then redirects to the extension's callback with the session in the URL fragment:

chrome-extension://<id>/callback.html#token=…&publicKey=…&expiresAt=…&apiUrl=…

A callback that does not start with chrome-extension:// is refused. The fragment is never sent to a server.

Memories

GET /memories → { "memories": [ … ] }
POST /memories → { "id": "…", "leafHash": "…", "anchored": false, … }

POST accepts { content, source, metadata }. content is trimmed, 1–10 000 characters. source is trimmed, 1–80 characters, and is merged into metadata.source.

Agent cases (read grants)

POST /agent-cases { expiresInMinutes?, maxReads? } → { id, url, expiresAt, … }
GET /agent-cases/:id/status → { readCount, remainingReads, revoked, expired }
POST /agent-cases/:id/revoke → { ok, revoked }
GET /agent-cases/:token → text/markdown (unauthenticated)

The consumption route is unauthenticated. The token is the credential. Unknown, spent, revoked, and expired tokens return the same 404 text/plain response. See Agent Cases.

Memory drops (write grants)

POST /memory-drops { expiresInMinutes?, maxWrites? } → { id, url, expiresAt, … }
GET /memory-drops/:id/status → { writeCount, remainingWrites, revoked, expired }
POST /memory-drops/:id/revoke → { ok, revoked }
POST /memory-drops/:token { content, source?, metadata? } → { ok, memoryId, anchored, leafHash, createdAt }

The consumption route is unauthenticated. Full detail: memory drops.


Conventions

Errors are { "error": "<message>" }. The extension API adds a requestId and, outside production, the underlying message.

Token amounts are decimal strings in base units. u64 values can exceed Number.MAX_SAFE_INTEGER.

Hashes and roots are lowercase hex, unprefixed. 32 bytes → 64 characters.

Timestamps are ISO-8601 with milliseconds, UTC.

Authorization failures use 404 to avoid confirming the existence of another user's resource.

No rate limiting. Neither service throttles. Deploy behind a gateway that does - see production readiness.