Documentation

Guides and reference for OutLayer verifiable compute and agent custody.

Docs navigation

Binding API reference

Every endpoint a bound-account integration touches, what it takes, what it answers, and how to read a refusal.

Read the concepts first. Personal Account Binding for an account you own, Agent Connect for a leased one. This page assumes you know which mode you are in and want the calls.

Two accounts, and which endpoint means which#

A bound wallet has two account identities holding two different kinds of money, and almost every integration bug starts by confusing them.

  • The executor — the wallet's own NEAR account, derived inside the enclave. It holds the gas. It is the account the chain sees signing.
  • The asset account — the bound account, whose name the agent acts under and whose balance the agent spends.

The rule that resolves it: everything under /wallet/v1/binding/ is about the ASSET account, and everything else under /wallet/v1/ is about the wallet's own account unless you ask otherwise. So /wallet/v1/balance and /wallet/v1/binding/balance are two different balances, and both answers are correct.

The read side also accepts account=asset on /wallet/v1/balance, which answers exactly as /binding/balance does. Omitting it still means the executor — the default does not follow the binding, because a number that silently changed which account it describes is the whole failure this split exists to prevent. On a wallet with no active binding, account=asset is refused rather than answered with the executor's figure under an asset label.

This is a URL split rather than a flag on the WRITE side because those are not the same operation. /wallet/v1/transfer signs a plain NEAR transfer from the wallet's account. /wallet/v1/binding/transfer signs a contract call against the asset account under an on-chain grant — a different signed object, a different set of refusals, a different approval path.

Authentication#

Wallet endpoints accept either credential; they identify the same wallet.

HeaderWho uses it
Authorization: Bearer near:<sig>The wallet's owner, signing with their NEAR key. This is the deterministic wallet derived from an account plus a seed.
Authorization: Bearer wk_…A wallet API key, for a randomly-derived wallet.
X-Binding-Webhook-SecretThe partner, on /wallet/v1/binding/events only. A shared secret, compared as digests. This is the one endpoint an outside party calls.

X-Wallet-Id is not a credential. It confirms the wallet the credential already names, and naming somebody else's is refused. A guest job needs it explicitly before the enclave will give the module its wallet imports — spending is opt-in, never ambient.

The endpoints#

CallAboutModes
PUT /wallet/v1/bindingRecord the bindingboth
GET /wallet/v1/bindingStatus, gas, versionsboth
DELETE /wallet/v1/bindingEnd it, cancel pending approvalsboth
GET /wallet/v1/binding/setup?kind=…The owner's installation transactionpersonal only
GET /wallet/v1/binding/balanceThe ASSET account's moneyboth
GET /wallet/v1/balance?account=assetThe same answer, on the general endpointboth
POST /wallet/v1/binding/transferSpend the asset account, simple formboth
POST /wallet/v1/binding/eventsPartner lifecycle newslease only
GET /wallet/v1/binding/eventsWhich account zones the webhook may namelease only
POST /wallet/v1/callSpend the asset account, full envelopeboth
GET /wallet/v1/addressThe executor's address, plus asset_account_id and executor_account_id — both always present for chain=near, null when there is no binding
POST /call/{owner}/{project}Run a job under the bound nameboth

PUT /wallet/v1/binding

Records the binding. It authorizes nothing on its own: what a bound agent may do lives on chain, and this row only says which wallet is allowed to ask.

Fieldhos_leasepersonal_account
asset_account_idRequired. The account the agent will act as.
kindOptional. Defaults to hos_lease, because the partner's own calls carry no kind.
owner_account_idRequired. The value House of Stake gave you at provisioning; relayed, stored, and checked for shape only. It is not returned — see below.Optional; if sent it must equal asset_account_id, because in this mode the owner IS the account.
impl_versionRequired, and gated against the versions this build can decode.Rejected. This mode is versioned by the account's code hash, which you do not declare. Rejected rather than ignored: sending it means the two modes have been confused, and silence would hide that.

A repeat PUT is idempotent, but only for the SAME binding. Naming a different asset account is refused rather than silently rebinding, and so is stating a different impl_version: the recorded version is what selects the decoder, and it is written once. To move a binding to a new implementation version, DELETE it and bind again. Both refusals are 409.

A binding starts pending and becomes active when the chain confirms the lane — the executor is in the account's control set, the account is active and unfrozen, the lease has not run out. Nothing you PUT can make it active.

GET /wallet/v1/binding

Re-reads the chain and answers with the current state. This is the endpoint to poll.

FieldMeaning
binding_statuspending · active · suspended · revoked. Only active runs. suspended is reversible and returns on its own once the chain agrees again; revoked is over.
status_reasonWhy the binding is not active — the fault class the last observation reported, in the same vocabulary a refused spend uses (executor_not_in_control_set, account_frozen, unsupported_wallet_implementation, ...). Absent while active. A pending personal binding reads executor_not_in_control_set until the owner adds the executor.
executor_account_idThe account that signs, and the one that needs gas.
gas_balanceThe executor's native balance, yoctoNEAR. Absent when the node would not answer — a balance nobody could read is not a balance of zero, and is never reported as one.
gas_balance_low, gas_balance_thresholdA computed flag and the figure behind it, so whoever funds the executor learns it is time without having to know our gas arithmetic. There is no low-gas webhook: you are already polling this endpoint.
impl_version, decoder_versionLease mode only. Absent for a personal binding, which has no implementation version.
no owner fieldDeliberately. owner_account_id is accepted at PUT as the provisioning receipt House of Stake hands you, and stored — but it is checked for shape only and compared against nothing, so returning it would present your own input as an established fact. Who holds a leased account is nft_item_info.owner_id on chain, and what ends a lane when it changes hands is the rotation pin: the chain's number, not anybody's claim.

GET /wallet/v1/binding/setup?kind=personal_account

Assembles the installation transaction for an account you own: one transaction, three actions, signer and receiver both your own account. We only assemble it — you sign it with any wallet. The response carries code_hash (the global contract the first action references, the same artifact the verifier pins) and transactions[]. An account that already runs code is refused, because deploying over it would not clear the old contract's state.

For kind=hos_lease this answers 400. Those accounts are provisioned by the partner and nobody signs a setup transaction — asking for one means you are in the wrong mode.

POST /wallet/v1/binding/transfer

A builder, not a second lane. What gets signed is exactly the envelope you could have posted to /wallet/v1/call yourself: same signed object, same pre-flight, same grant rules, same policy, same approval trigger. It exists so that getting a nested base64 envelope slightly wrong is not the normal experience.

{
  "to":     "friend.near",      // the LOGICAL recipient — not the token contract
  "amount": "1000000000000000000000000",
  "token":  "usdc.near",        // optional; absent = native NEAR
  "memo":   "invoice 41"        // optional
}

Amounts of the money a wallet MOVES are always the token's smallest unit as a decimal string — yoctoNEAR for native, and no fractional form. The exception is money denominated in dollars rather than in a token: initial_deposit_usdc on /wallet/v1/create-payment-key is written the way a price is written, "2.00". Nothing on this page is a price.

POST /wallet/v1/binding/events

Lifecycle news from the partner, authorized by X-Binding-Webhook-Secret. Deliberately powerless:

{ "asset_account_id": "alpha.partner.near", "event": "revoked" }
→ { "binding_status": "revoked" }   // or "suspended", "active", "unbound"
  • The body is a hint, not an instruction. A revoked event revokes nothing. We drop the cached observation, re-read the chain, and the chain decides. A leaked secret must not become the ability to switch off an agent by name.
  • Only leased bindings are reachable, and only accounts inside the configured zones. An account outside them answers unbound rather than an error, so the webhook cannot be used to probe which accounts we know about. GET the same path with the same secret to read the zones you may name.
  • Cancelling pending approvals is the part only we can do. Nothing on chain would ever tell us to, so a binding this call finds revoked cleans them up exactly as DELETE does. That is the real reason this endpoint exists.

A deployment with no secret configured answers 503 — the same answer for a correct secret and a wrong one, so a caller cannot tell "not configured" from "not authorized" by the response.

Running a job under the bound name#

Set use_bound_identity and the guest runs as the bound account. Two doors, and they must answer identically:

POST /call/{owner}/{project}
X-Payment-Key: <a key the wallet owns>
{ "input": {...}, "use_bound_identity": true }

# or on chain
request_execution({ ..., use_bound_identity: true })

Over HTTPS the binding is found through the wallet the payment key names; on chain it is found by the calling account, because that is all an on-chain request carries. A key that names no wallet, or a wallet with no active binding, is refused before anything is charged.

What travels is a claim. The worker re-verifies it against the chain inside the enclave before setting the guest's environment, and refuses the job if the chain disagrees. Billing never moves: the paying key stays the payer throughout.

A borrowed name is not a borrowed purse. The flag changes who the guest IS. It does not change whose money it spends or whose limits apply — those stay the wallet's. Binding to an account with a laxer policy does not inherit it.

One wallet is one purse#

The velocity counters — daily, hourly, monthly, and the hourly transaction count — are kept per WALLET and nothing else. A spend made through the HTTPS API and a spend a guest makes from inside a job meet the same daily figure. There is no per-door budget and no way to get a second one.

Two consequences worth planning around. A job that spends counts against the same ceiling your dashboard calls do, so a busy agent can exhaust a limit an operator thought was theirs. And every request that reached the chain consumes the transaction count, whether or not it moved anything — one moving both NEAR and a token counts twice. A request refused before it was sent, by the policy or by a pre-flight, costs nothing.

A refusal names the window it hit (Daily, Hourly, Monthly) and the token, with the arithmetic: spent + amount > limit. Read the window before assuming which cap you met.

Reading a refusal#

A bound-account refusal is structured, because you route on it:

HTTP 403
{
  "error": "agent_connect_denied",
  "class": "receiver_not_granted",
  "terminal": false,
  "message": "...",
  "promise_index": 1,          // which promise in the envelope, when one is to blame
  "additional_violations": 2   // how many further problems the request carries
}

terminal is the field that matters most. An agent that retries a spent grant spins forever while its owner is never told to issue a new one. Retry a reversible refusal; escalate a terminal one to whoever can change the state on chain.

Only ONE class is reported even when several rules are broken, and additional_violations says how many more there were. The order is deliberate: an expiry answers before a malformed envelope, so nobody is sent to fix the shape of a request that no grant would have covered anyway.

Classes

ClassWhat happenedDo
executor_not_in_control_setThe account no longer lets this executor act for it.Ask the account's owner to re-enable the extension.
account_frozenFrozen by its holder or by an authority.Reversible — wait, or ask for it to be lifted.
account_not_activeListed, settling, suspended or parked.Reversible — the account is mid-lifecycle.
lease_expiredThe lease ran out.Terminal. A new lease is a new binding.
grant_missingNo spend grant at all.The account's owner issues one.
grant_expiredThe grant's own deadline passed.Ask for a new grant. Retrying will not help.
grant_exhaustedThe grant's budget is spent.Re-granting raises the ceiling; the meter keeps its total. Revoking the grant is what resets it.
receiver_not_grantedThe grant does not name this recipient.Send to a granted receiver, or ask for a wider grant.
token_not_granted, token_budget_exceededThe grant does not cover this token, or not this much of it.Per-token budgets are separate from the overall one.
grant_shape_violation:*The envelope is not a shape a grant can cover — a method the grant does not allow, an action it never permits (deploying code, adding a key), a call that must stand alone in its promise, a deposit on a call, arguments that will not decode.The suffix names which rule. promise_index names which promise.
insufficient_vs_reserveThe spend would leave the account below its own balance floor. The floor tracks live storage, so only the chain knows it.Spend less, or the account gets funded.
unsupported_wallet_implementationThe account runs a version this build has no decoder for.Terminal for this build. We will not sign a nested request we cannot read.
unrecognized_wallet_codeThe account runs code we do not recognize — a personal account redeployed under us, or a leased account on an implementation we were not told about.Reversible. Re-install the supported contract, or tell us the new implementation and we allowlist it; the binding resumes on its own.
registry_disagreesThe collection a leased account says it belongs to has no record of the token the account names — nft_token answered null or refused. The collection vouches only that it minted the name; ownership lives on the account and is not compared.Reversible. The registry may trail the account by a block; the binding resumes once the record is there.
chain_status_unreadableThe account answered in a shape we cannot read. Schema drift shows up as itself, never as a made-up "lease expired".Reversible. Tell us — it is our decoder, not your call.
no_bound_identityuse_bound_identity was set on a wallet with no active binding, or a key that names no wallet.Check GET /wallet/v1/binding, or drop the flag and run under your own name.

Which failures are worth retrying#

StatusMeansRetry?
402wallet_underfunded / vault_underfunded — it names what is held, what the operation costs, and which account is short.After funding that account.
403agent_connect_denied or policy_denied.Only if the class says reversible. Read terminal.
409wallet_busy — another operation holds the wallet. The answer carries in_flight_request_id to poll, or in_flight_operation when the id is not yet readable.Yes, after the named request finishes.
422chain_refused — the chain answered about the account and the answer was no.No. Nothing changes by asking again.
503 + Retry-Afterchain_unavailable, keystore_error, upstream_unavailable — a node or a service did not answer this second.Yes, after the interval given.
503 without oneservice_unavailable — the feature is switched off on this deployment.No. Polling is polling forever.

The presence of Retry-After is the signal, not the status code. Two 503s mean different things and the header is what separates them.

Things that catch people#

  • Amounts are strings in the smallest unit wherever a token is being moved — which is everything on this page. (Dollar figures elsewhere in the API, such as a payment key's initial_deposit_usdc, are written as prices instead.) "0.2" in a policy limit is not 0.2 NEAR — it is a value we refuse to apply, and the wallet stops signing until it is corrected.
  • A pending binding authorizes nothing. Poll GET /wallet/v1/binding until active; do not treat a 200 from PUT as readiness.
  • Fund the executor, not the asset account, for gas. They are different accounts and the error messages name which one is short. gas_balance_low is on the binding read for exactly this.
  • A refused call still costs the transaction count if it reached the chain. The cap is transactions, not successful ones — a limiter you could walk past by making your own calls fail would not be one.
  • An empty envelope is a real transaction. {"request":{}}, {"request":[]} and {"request":{"external":[]}} all decode to a request that does nothing, and all three are signed and sent rather than refused. Nothing moves and no rule is bypassed — there is nothing to bypass — but the executor pays gas for the call and it consumes one slot of the hourly transaction cap like any other call that reached the chain.
    We do not refuse them, and that is deliberate: the wallet contract runs nonces.check_cleanup() before it looks at the request, so an empty envelope is a supported way to trigger that maintenance without moving value. Refusing it would take away an operation the contract offers, to save gas the wallet's own owner is spending. If you are seeing them and did not mean to, the cause is upstream: something built an envelope with no actions in it.
  • The grant and your policy are two ceilings. Both apply, independently, and either can refuse. The grant lives on the account and we cannot change it; the policy is yours.