On this page

Wallet-owned accounts and signed REST requests

Center's API lets software request data or actions through web requests (REST). To use protected routes, select Sign in on Accounts with your email, phone, social account, or an existing wallet. Your account wallet signs the request that creates or loads your account. The network used to identify that account is its authority chain. The account identifier is eip155:<authorityChainId>:<lowercaseOwnerAddress>.

Owners can let a bot sign API requests using its own locally generated secp256k1 key. The permissions form a grant; each permission is a scope. The private key stays with the client. Center stores the public address, fixed grant ID, scopes, label, expiry, and revocation time. This scheme uses request signatures, with no API key, bearer token, or server-held wallet key. See the glossary.

Public discovery does not require a bot grant. Owner enrollment is an independently signed request and works before an account or bot exists. A bot cannot enroll an account on behalf of an unrelated owner.

Start with the client

Sign in on Accounts, then create a connection under 02 / API access. Choose its permissions and expiry and approve the locally generated bot key. The bot then authenticates API requests automatically through SignedRestClient; routine reads and planning need no owner wallet prompt. See the setup walkthrough.

import { SignedRestClient } from "@juicebox/center-client";

// botSigner is the registered key held in your local signer or secret store.
// accountId and grantId are the public identifiers returned at registration.
const center = new SignedRestClient({
  audience: "https://juicebox.center",
  accountId,
  grantId,
  signer: botSigner,
});
const account = await center.request({ requestTarget: "/api/v1/accounts/me" });

The client creates the signature, nonce and expiry, and sends the exact signed request bytes. Use the wire reference below when implementing another client. The owner address identifies whose account is being accessed; the signer is the owner or the registered bot acting for that account.

API access and wallet actions

Layer What you approve What happens afterward
API access A bot key, scopes and expiry The client signs requests using that key. Revoke its grant to end access.
One wallet action Exact calls, amounts and execution deadline The wallet signs the returned transaction or approval document.
Recurring wallet actions A separate supported session with actions, budgets and expiry The delegated key can authorize only what the verified wallet policy allows.

API read, plan and relay scopes do not grant wallet spending authority. Use owner-approved transactions for individual actions and wallet sessions for optional recurring execution. Direct and prepaid bot submissions require fresh owner dispatch approval; supported Safe execution uses its time-limited SafeOp approval.

Request signatures

Every protected request signs structured fields using the EIP-712 standard and Center's CenterRequest format. A single-use random value (nonce) prevents replay. An idempotency key identifies retries of the same operation. The exact schema and browser-compatible helpers live in src/rest/auth/signatures.ts.

Header Meaning
X-Juicebox-Account Wallet-owned account identifier
X-Juicebox-Signer Owner wallet or bot public address
X-Juicebox-Grant Bot grant UUID; omit for the owner
X-Juicebox-Issued-At Integer Unix seconds
X-Juicebox-Expires-At Integer Unix seconds, after issuance and within 300 seconds
X-Juicebox-Nonce 0x followed by 64 lowercase hex digits from 32 random bytes
X-Juicebox-Signature EIP-712 signature
Idempotency-Key Optional operation retry identifier; covered by the signature

The EIP-712 domain is { name: "Juicebox Center REST", version: "1", chainId: authorityChainId, salt: keccak256(utf8(audience)) }. The audience is the configured public service URL, without a trailing slash; production uses https://juicebox.center. It is not inferred from an untrusted Host header.

The signed message contains, in schema order:

audience: string
accountId: string
signer: address
grantId: string
method: string
requestTarget: string
contentType: string
bodyHash: bytes32
issuedAt: uint64
expiresAt: uint64
nonce: bytes32
idempotencyKey: string

Use an empty string for an absent grant, content type, or idempotency key. Sign the uppercase HTTP method, exact path plus raw query including /api/v1, and the exact Content-Type value sent. bodyHash is Keccak-256 of the transmitted body bytes, including the empty byte array for a bodyless request. JSON whitespace and query parameter order are significant. Do not sort or reserialize a request after signing it. Compressed request bodies are rejected.

The HTTP integration must supply the original Node IncomingMessage.url to readSignedRequest or the account-router request-target resolver. Using only a parsed URL can erase distinctions such as dot segments before verification.

The service verifies the signature, then atomically rechecks the account or grant and consumes the nonce while holding the account lock. A nonce is single-use for the entire account. A 30-second future clock allowance is permitted; expired requests and validity windows exceeding five minutes fail. Waiting for an account lock cannot extend a request or grant's validity.

PostgreSQL uses its own clock_timestamp() for acceptance, grant expiry, and nonce cleanup across all service instances. It rechecks validity after nonce insertion before committing, so asynchronous storage work cannot admit an expired replay after cleanup removes the original nonce. A node's preliminary clock check may reject a request, but cannot override the shared database's authorization time.

Retries use a new nonce and signature. Reuse the same signed idempotency key where the operation supports idempotency. Authentication itself does not cache responses or make account mutations idempotent. A signature accepted by the server can consume its nonce even if the subsequent operation fails. Never repeat a financial operation merely because its HTTP response was lost.

Enrollment and profile routes

The account router mounts under /api/v1. Profile fields remain available to API clients: a display name, biography, and optional avatar URI. These fields do not establish identity or grant permissions.

Method and path Authority Input / response
POST /api/v1/accounts/enroll Owner signature; no previous account required Empty JSON object; returns { account }
GET /api/v1/accounts/me Owner or bot with read Returns { account }
PATCH /api/v1/accounts/me Owner Replaces profile { displayName, bio, avatarUri }; returns { account }
GET /api/v1/accounts/me/bots Owner Returns { bots }
POST /api/v1/accounts/me/bots Owner plus bot possession proof Returns { bot } with its new UUID
DELETE /api/v1/accounts/me/bots/:grantId Owner Revokes that exact grant; returns { bot }

Reenrollment with a fresh owner-signed request returns the existing account without resetting its profile or bots. The authority chain and owner cannot be edited through profile routes. Contract-wallet owners require a configured EIP-1271 verifier on the account's authority chain; unavailable or failing verification never authorizes a request. Verification has a five-second deadline and passes an abort signal to the verifier's RPC adapter. Bot keys must prove EOA signature possession.

Profile names and bot labels allow 120 UTF-8 bytes, biographies 2,000 bytes, and avatar URIs 2,048 bytes. Avatar URIs use HTTPS or IPFS and are never fetched by the authentication service. The route body limit is 64 KiB, with a 15-second read deadline.

Register a bot

  1. Generate a new secp256k1 keypair on the client with a cryptographically secure generator. Keep the private key in the client's secret storage.
  2. Choose a label, expiry, and scopes. An omitted scope list means ["read"]. Generate the nonce that the owner's registration HTTP request will use.
  3. Sign buildBotProofTypedData(audience, proof) with the bot's key. The proof contains accountId, botAddress, the ordered scope list, expiry, label, and ownerRequestNonce under the distinct CenterBotProof type.
  4. Include that proofSignature in the JSON registration body. The owner signs the HTTP request including the hash of this entire body, using the same nonce as ownerRequestNonce.
  5. Store the returned grant UUID alongside the bot's local key. Include it in X-Juicebox-Grant on subsequent requests signed by the bot.

Registration input:

{
  "botAddress": "0x...",
  "scopes": ["read", "plan", "relay"],
  "expiresAt": 1900003600,
  "label": "My local agent",
  "proofSignature": "0x..."
}

The addresses, timestamp, and signature above are illustrative placeholders. The expiry must be in the future and no more than one year away. Scopes must use one of three cumulative profiles in canonical order: ["read"], ["read", "plan"], or ["read", "plan", "relay"]. Incomplete, repeated, or reordered scopes are rejected; the server never expands or sorts a signed grant. The proof must contain the same ordered list. The owner signs every new grant; bots cannot create grants, escalate scopes, edit owner profiles, or revoke other bots.

Scope Permission
read Protected reads
plan Preparing reviewable unsigned transaction plans
relay Relaying separately signed wallet transactions through allowed routes

Planning requires read so the bot can retrieve and review its saved plans; relaying also requires plan. Plans belong to the exact bot grant that created them. A different grant, including a replacement using the same bot address, cannot take over their submission. The account owner can read all plans belonging to the account. Grant renewal does not transfer an old grant's plans.

None of these API permissions grants Juicebox operator permissions, token allowances, access to an owner private key, or authority to sign an onchain transaction. A prepared plan contains no signature. The wallet signs its own transaction; the relay submits only those signed bytes.

Fresh owner approval for transaction dispatch

An Ethereum transaction signature contains no signing timestamp. Possessing signed transaction bytes does not prove that the wallet owner recently approved their submission. Every mutation that could move funds therefore requires fresh owner approval before dispatch, even when a bot has the relay scope. There is no standing spending-budget exception in this API.

A newly verified owner-signed HTTP request already provides fresh approval for the exact request body. A bot-signed submission requires an additional ownerApproval object signed by the account's wallet owner. The owner signs buildTransactionApprovalTypedData(audience, claims) from src/rest/approvals.ts. The private key remains in the owner's wallet. Bot keys and API scopes cannot sign on the owner's behalf.

The EIP-712 primary type is CenterTransactionApproval, distinct from request authentication and bot-registration proofs. It uses the same REST domain name, version, configured audience salt, and account authority chain. Its message is:

audience: string
accountId: string
principalId: string
planId: string
commitment: bytes32
stepIndex: uint32
transactionHash: bytes32
issuedAt: uint64
expiresAt: uint64
nonce: bytes32

The submission's ownerApproval JSON contains all those fields except audience (supplied by the configured service origin), plus signature. Times use integer Unix seconds. transactionHash is Keccak-256 of the exact serialized signed transaction bytes. The plan commitment comes from the saved immutable plan; principalId identifies its exact originating grant. Generate a fresh random 32-byte nonce for each new approval. Review the plan and transaction before asking the owner to sign.

An approval lasts at most five minutes; a 30-second future-clock allowance matches request authentication. The server derives the owner and authority chain from the expected account identifier, then checks its EOA or configured EIP-1271 signature. Verification is bounded to five seconds and rechecks expiry after awaited work. Durable dispatch admission rechecks the verified approval window using the database clock while holding the transaction's authorization lock. An approval that expires during preparation or a lock wait cannot authorize a new dispatch. A previously admitted dispatch can already be in flight.

An approval is bound to one account, grant, plan, commitment, step, and transaction hash. Reusing its nonce or signature with another effect fails. The same exact approval can be checked again during its validity window: permanent transaction hash and wallet-nonce reservations prevent an additional distinct effect. This is why approval nonces do not require a separate replay table. A lease retry that actually dispatches again still needs a valid approval at its new admission.

Reading receipts, reconciling a known transaction, and returning an idempotent result without dispatch do not require a new owner approval. A lost HTTP response does not justify signing a different financial transaction; reconcile the original hash first.

Sponsored publication uses a separate CenterSponsorshipApproval type. Its message contains audience, accountId, principalId, sponsorshipId, commitment, submissionHash, issuedAt, expiresAt, and nonce, in that order. The two hashes and nonce are bytes32; timestamps are uint64; other fields are strings. Use buildSponsorshipApprovalTypedData(audience, claims) and attach its resulting signature to the same claims as ownerApproval alongside the ordered forwarding signatures in the publication body. A transaction approval cannot authorize sponsored publication, and a sponsorship approval cannot authorize direct relay.

Existing preparation responses provide the necessary approval inputs:

Approval field Direct transaction Sponsored publication
accountId Originating client's account identifier Same
principalId bot:<originatingGrantId> Same
Preparation identifier planId: plan.id sponsorshipId: preparation.id
commitment plan.commitment preparation.commitment
Exact effect stepIndex and transactionHash: keccak256(rawSignedTransaction) submissionHash: sponsorshipSubmissionHash(preparation.commitment, signatures)
Freshness New issuedAt, expiresAt, and random nonce Same

The browser-safe sponsorshipSubmissionHash helper uses the server's SHA-256 canonical JSON identity for the commitment and ordered, lowercase forwarding signatures. It preserves their order and rejects malformed signatures. This submission hash differs from an Ethereum transaction hash.

The production dispatch authorizers in src/rest/dispatchAuthority.ts read the authenticated request from async-local context. They require the exact account and originating principal, preventing another request or grant from supplying authority. For an owner shortcut, they also check the exact signed POST path and body: the selected step's raw transaction hash, or the sponsorship's ordered signature hash. A signed GET, profile update, different step, or duplicate bundle step cannot serve as a dispatch approval. The original owner HTTP signature is verified again before returning its fresh window to durable admission.

For direct bundles, each bot submission carries its own ownerApproval for its step. The owner can instead sign the exact bundle HTTP request. Approval objects are authorization metadata inside the signed HTTP body: changing an approval changes the request's exact idempotency identity. Keep the permanent transaction hash unchanged when reconciling or renewing consent to its dispatch.

Revocation and concurrent work

Revocation changes durable authorization immediately at its database commit. Grant IDs and permissions are immutable: renewing or changing permissions creates a new grant. Requests using an expired or revoked generation fail even when the same public address has another valid generation.

Authentication, nonce acceptance, and revocation use the same account row lock. Durable relay admission must also check the original actor under this lock in the same transaction that claims the job. The exported assertRestActorActive(client, actor, scopes, now) supports that integration. The lock order is account, then plan/job, then wallet nonce. Do not hold the database lock while making an RPC request.

A revocation committed before job admission prevents the claim. A job already claimed under that lock is in flight and may submit afterward; revocation cannot undo a transaction already sent to a chain. A standalone assertActive check followed by a later unlocked claim is insufficient for this guarantee.

The default bounds are 100,000 accounts, 100 retained bot-grant records per account, and 1,000 unexpired request nonces per account. Retained revoked grants count toward the grant bound so an old identifier is never silently revived. Limits return explicit errors. The nonce bound reserves 32 slots for verified owner requests: bots cannot exhaust those slots and prevent an owner from revoking a grant. Merely omitting the grant header does not establish owner authority. Expired nonces are removed during bounded per-account cleanup; cleanupExpiredNonces supports indexed background cleanup in batches of at most 10,000. Active request validity is checked before expired nonce records can be reused.

Agent clients

Clients need a signer that calculates a fresh signature for every protected HTTP request. Static headers or a pasted bearer token cannot implement this protocol. A local signing adapter can keep the key outside an agent conversation while forwarding its requests. Ordinary MCP URL configuration does not automatically add this REST signing behavior.