Skip to main content

HTTP API

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

ServicePortAuthConsumers
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 }

400 { "error": "invalid memory" } if 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

The same shape, one memory. 404 if it does not exist or belongs to another user - the two are indistinguishable to the caller, by design.

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 exactly once, never again
{
"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 - an UNSIGNED transaction for the caller's wallet to sign and send
{ "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 never signs. It prepares. A full withdrawal is always permitted 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. It returns 404 text/plain for an unknown, spent, revoked, or expired token, indistinguishably. Full detail: 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 of base units. Never numbers. u64 exceeds Number.MAX_SAFE_INTEGER.

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

Timestamps are ISO-8601 with milliseconds, UTC.

404 over 403. A resource belonging to another user does not exist, from your perspective. The API does not confirm the existence of things you cannot read.

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