# Guveno developer documentation > API reference v1.1.1, last updated September 22, 2026. Rendered from https://guveno.com/docs, section by section. Guveno is a self-custodial crypto wallet platform for business. People run it from a web dashboard; scripts and AI agents run the same wallets through the REST API, the Node and Python SDKs and the CLI (`npm install -g @guveno/cli`), each with an API key scoped to the wallets and actions it needs. There is no MCP server: agents use the CLI as a shell tool, or the SDKs. A shorter index for agents is at https://guveno.com/llms.txt. ## Contents - Getting started: Introduction, How wallets work, Authentication, API keys, Roles & teams, Permissions & scoping - API reference: Vaults, Wallets, Assets, Balances, Transactions, Withdrawals, Webhooks, Errors & rate limits, Network status - Signing & custody: Hot wallets, HSM & KMS - SDKs & tools: Node.js SDK, Python SDK, Command-line (CLI), Postman collection, Advanced configuration ## Introduction The Guveno API is a JSON-over-HTTPS interface for managing your crypto wallets. Create wallets, watch balances and transactions, and prepare and broadcast withdrawals straight from your own systems. ### The five things to know If you skim everything else, keep these five: - Every request carries an API key: `Authorization: Bearer gv_live_...`. - Amounts are decimal strings in whole units (`"1.5"` ETH), never floats. - Money in: register a webhook and credit a balance only on `deposit.confirmed` (see **Webhooks**). - Money out: prepare → sign → broadcast, with an `Idempotency-Key` on each call. Signing always happens on your side (see **Withdrawals**). - Guveno never holds spendable keys. Lose your recovery phrase and passphrase, and nobody can recover the funds (see **How wallets work**). ### Base URL All requests go over HTTPS and exchange `application/json`. ``` https://api.guveno.com/v1 ``` If you run a self-hosted Guveno server, use your own host with the same `/v1` path. ### Conventions - Authentication is a bearer token on every request (see Authentication). - Timestamps are ISO-8601 in UTC, e.g. `2026-06-14T12:00:00.000Z`. - Resource IDs are integers; list endpoints paginate with an opaque `cursor`. - Amounts are decimal strings in whole units (never floats), to preserve precision. ### Quick start - **1.** Create an API key in the dashboard (**Developer → API Keys**) and scope it to the wallets and actions it needs. - **2.** Check the key works: ``` curl https://api.guveno.com/v1/me \ -H "Authorization: Bearer gv_live_your_key_here" ``` It answers with the account the key belongs to: `id`, `email`, the optional `firstName` and `lastName` its owner set in the dashboard, the company membership and your encryption key. The two name fields are cosmetic and stay `null` until someone fills them in. - **3.** List the wallets it can see: ``` curl https://api.guveno.com/v1/wallets \ -H "Authorization: Bearer gv_live_your_key_here" ``` From here, go where your integration goes: receiving money is **Webhooks**, sending money is **Withdrawals**, and if you'd rather not hand-roll HTTP, the **Node.js** and **Python** SDKs wrap all of it. Source: https://guveno.com/docs#introduction --- ## How wallets work Guveno is non-custodial: you hold the keys, and Guveno holds none of the spendable secrets. It watches your addresses and prepares transactions, but it can never move funds on its own. ### The short version Guveno watches your on-chain addresses, tracks balances and transactions, and assembles unsigned transactions for you. The private keys that actually authorize spending are generated on your side and never leave your control in a form Guveno can use. Moving funds always requires a signature only you can produce. ### Vaults, wallets and addresses Everything you hold in Guveno sits in a three-level hierarchy: - A **vault** is the key-management boundary: one recovery phrase, identified by its fingerprint, with a name, an optional team scope, and a list of the members who hold a sealed copy of the phrase. Who can see a vault and who can sign for it are decided here. - A **wallet** is one chain and network inside a vault, derived from the vault's phrase at an account-level derivation path (for example `m/44'/60'/0'/0` for Ethereum). A treasury vault might hold a Bitcoin wallet, an Ethereum wallet and an XRP wallet, all from the same phrase. A vault can hold two wallets on the same chain only at different derivation paths, so two wallets never derive the same address. - An **address** is one receiving address inside a wallet, at `/`. Bitcoin wallets typically hold many; account-based chains often need just one. ``` Treasury vault (one recovery phrase, shared with the finance team) ├── Bitcoin wallet m/84'/0'/0'/0 │ ├── bc1q...123 /0 │ ├── bc1q...456 /1 │ └── bc1q...789 /2 ├── Ethereum wallet m/44'/60'/0'/0 │ └── 0xabc... /0 └── XRP wallet m/44'/144'/0'/0 └── r9Hk... /0 ``` Wallets inherit their vault's team scope and key access. Re-scoping a vault moves every wallet in it at once, and sharing a vault with a member gives them every wallet in it. See Vaults in the API reference. ### Where keys live - Each wallet is a standard hierarchical-deterministic (HD) wallet derived from a BIP39 recovery phrase that is generated in **your** environment: the dashboard in your browser, the SDK, or the CLI. - Before that secret is ever sent anywhere, it is encrypted to your own public key. Guveno's servers only ever receive ciphertext they cannot open. ### What the server stores The server keeps wallet *metadata* (names, chains, networks, and the derived public addresses it needs to watch) together with an **encrypted** copy of each wallet secret that it cannot decrypt. Every member has a personal encryption keypair whose private half is protected by a passphrase only that person knows, so the encrypted secret can only be opened on a member's own device. ### Optional key file In encryption setup or Profile → Wallet encryption, you can also require a private file of any format, including an image (non-empty, up to 20 MiB). Setup asks you to enter the password and select the file again to prove you can unlock before saving. Both are then required to unlock and recover your wallet secrets through Guveno; neither is uploaded. Keep a separate backup of the exact original file. Copying and renaming work; editing, resizing, recompressing, or changing image metadata does not. Public or shared files add little protection. Guveno cannot reset or recover either credential. Independently backed-up recovery phrases can still recover their wallets. This protects your access across all wallets; other members keep their own access. Changing protection preserves addresses and recovery phrases. Previously saved encrypted backups retain their original credentials, and already unlocked sessions remain usable until locked. The dashboard saves only an encrypted envelope via the session-only `PUT /me/encryption-key` endpoint. Initial setup supplies `publicKey` and `encryptedPrivateKeyJson`. Changes must preserve the public key and also supply the previous envelope as `expectedEncryptedPrivateKeyJson`; a stale change returns 409. API keys cannot call this endpoint. Never put passwords, file contents, or file hashes in an API request. ### Signing a withdrawal The spend flow is split so the secret never reaches the server: - **1. Prepare**: the server builds an unsigned transaction from the wallet's public data. - **2. Sign**: you decrypt the wallet secret locally and sign the transaction in your own environment. - **3. Broadcast**: you return the signed transaction; the server submits it on-chain and tracks it. At no point does Guveno hold a key that can spend. That is also why API keys can prepare and broadcast but never sign (see Withdrawals). ### Sharing with your team To give a teammate access to a vault (and so to every wallet in it), an existing member re-encrypts the vault's recovery phrase to the new member's public key, on their own device. Guveno relays the resulting ciphertext but never sees the secret itself. Access is per-member and per-vault, and can be revoked (see Vaults and Permissions & scoping). ### What this means for you - Guveno, or anyone who compromised it, cannot move your funds, because there are no spendable keys to steal. - You are responsible for your recovery phrase and passphrase. If they are lost, the funds cannot be recovered by anyone, including Guveno. Keep secure, offline backups. Source: https://guveno.com/docs#architecture --- ## Authentication Every request authenticates with an API key: a long-lived bearer token you generate in the dashboard and feed to your server, the SDK, or the CLI. ### The API key The API is API-key-only. Generate a key in the dashboard under **Developer → API Keys**, scope it to the wallets and actions it needs, and use it as the bearer token on every request. There is no login, signup, or session flow to script. Account setup (creating the company, members, multi-factor auth, your encryption key) happens once in the dashboard, and everything your integration does afterward uses the key. Keys are prefixed with `gv_live_`. The secret is shown **once** at creation. Store it somewhere safe; it can't be recovered. See **API keys** for format, expiry, and revocation. ### The Authorization header Send the key in the `Authorization` header on every request: ``` Authorization: Bearer gv_live_your_key_here ``` ``` curl https://api.guveno.com/v1/wallets \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Base URL All requests go to the production API below. The SDK and CLI use it by default, so you never need to set it. Self-hosted servers can point at their own host; see **Advanced configuration**. ``` https://api.guveno.com/v1 ``` ### What an API key can do - **It can** do exactly the actions you granted, on exactly the wallets you granted (see **Permissions & scoping**): read wallets, balances and transactions, generate addresses, propose withdrawals for someone to approve, prepare/broadcast withdrawals, plus two global actions (create wallets, manage webhooks). Everything else returns `403`/`404`. `GET /me` always works. - **With a signing action** (`withdrawals:create` or `wallets:generate_address`) it can also read that wallet's encrypted secret (`GET /wallets/:id/secret`, audited) so the SDK/CLI can unseal it locally to derive an address or sign. - **It cannot** manage the account: creating, updating, or revoking API keys, inviting members, changing your password, and setting up your encryption key are dashboard-only and reject API keys with `403`. ### Keeping keys safe Treat keys like passwords. Never embed them in a browser app, mobile app, or public repository; they belong only on your servers. Scope each key to the fewest wallets and actions that do the job, optionally lock it to your server's source IPs, and revoke any key you suspect is exposed. Source: https://guveno.com/docs#authentication --- ## API keys Generate, scope, and revoke keys from the dashboard. ### Generating a key - Open the dashboard and go to **Developer → API Keys**. - Click **Create API key**, give it a name, choose its permissions and (optionally) an expiry date. - The secret is shown **once**. Copy it immediately and store it somewhere safe; it can't be recovered. ### Scoping a key A key holds an explicit list of allowed actions rather than a role. You grant wallet-scoped actions**per wallet** and a couple of global actions that aren't tied to a wallet; see**Permissions & scoping** for the full action list. You can only grant actions you hold yourself, and signing actions only on wallets whose key you can access; in the dashboard the rest are disabled. ### Source IP allowlist Optionally restrict a key to a set of source IPs. Provide individual IPv4/IPv6 addresses or CIDR ranges (e.g. `203.0.113.4`, `10.0.0.0/24`, `2001:db8::/32`). A request from any other address is rejected with `401`. Leave the list empty to allow any source. ### Key format Keys are prefixed with `gv_live_` followed by random characters: ``` gv_live_8Fz2pQ9rX1... ``` Only a short prefix is ever shown again in the dashboard, so you can recognise a key without revealing it. ### Expiry A key can be created with an expiry date or with no expiry at all. Once a key passes its expiry it stops working and the API returns `401`. ### Updating & revoking Edit a key's permissions, wallets, IP allowlist, or expiry at any time, or revoke it; revoked keys stop working immediately. The secret is never re-shown or rotated. For security, keys are created, updated, and revoked only in the dashboard, never through the API. ### Verification Creating, updating, and revoking a key are sensitive actions: the dashboard asks you to re-verify your identity (your authenticator, a passkey, or an emailed code) before the change is applied, and every change is recorded in the company activity log. Source: https://guveno.com/docs#api-keys --- ## Roles & teams Every member of your company holds one role. The role decides what they can administer and whether they may spend; a separate per-vault key-access grant decides which vaults they can actually sign for. Teams narrow what non-admin members can see. ### Roles A company has five roles. Owners and admins run the organization and see every wallet; members do the day-to-day work; viewers read; signers sign. Members are invited from the dashboard (Company, then Members) and an invite that names no role joins as a `viewer`. Only owners can change a member's role or add another owner, and the last owner can never be removed or demoted. - **owner**: full control, including billing, member roles and deleting the company. - **admin**: everything an owner does except changing roles and adding owners. - **member**: the ordinary operator. Reads its teams' wallets, requests withdrawals for someone to approve, generates deposit addresses, and builds integrations with API keys and webhooks. Never holds a vault key and never sends a withdrawal. - **viewer**: read-only on its teams' wallets. - **signer**: the only role besides owner and admin that can hold a vault key and send withdrawals, on its teams' wallets. Part of the Enterprise plan. ### What each role can do | Capability | `owner` | `admin` | `member` | `viewer` | `signer` | | --- | --- | --- | --- | --- | --- | | Read their teams' wallets, balances, transactions | ✓ | ✓ | ✓ | ✓ | ✓ | | See every wallet regardless of team | ✓ | ✓ | | | | | Create, rename and archive vaults and wallets | ✓ | ✓ | | | | | Decide which teams a wallet belongs to | ✓ | ✓ | | | | | Generate deposit addresses (server-side, from the account xpub) | ✓ | ✓ | ✓ | | ✓ | | Register client-derived addresses (with key access on the vault) | ✓ | ✓ | | | ✓ | | Request a withdrawal for someone to approve | ✓ | ✓ | ✓ | | ✓ | | Send withdrawals (with key access on the vault) | ✓ | ✓ | | | ✓ | | Hold key access to a vault | ✓ | ✓ | | | ✓ | | Share or revoke key access | ✓ | ✓ | | | | | Manage teams and team membership | ✓ | ✓ | | | | | Invite and remove members | ✓ | ✓ | | | | | Change a member's role, add owners | ✓ | | | | | | Create API keys, manage webhooks | ✓ | ✓ | ✓ | | | | Activity log, exports, billing | ✓ | ✓ | | | | > Sending funds needs three things at once: a role in the send row above, a key-access grant on the vault being spent from, and team access to the wallet itself. None is enough alone. Owners and admins see every wallet in the company but still cannot sign for a vault they have not been granted. ### Key access A vault is one recovery phrase. Each member who may use it holds their own copy, encrypted to their personal key so the server never sees the phrase. Whoever creates a vault holds it from the start, and shares it with other members from the vault's key holders page in the dashboard, which re-encrypts the phrase for that member on their device. Viewers and members whose invite is still pending cannot receive a copy. Sharing a key is a dashboard ceremony, not an API call, and it is two-sided. An owner or admin who already holds a copy seals one for the member and offers it; the member accepts or declines. Until they accept, nothing is in their account and they can sign for nothing. Both halves ask for a second factor, an unanswered offer expires after seven days, and the whole custody circle (everyone holding that key, plus the company's owners and admins) is emailed once it is accepted. API keys cannot read, offer, accept or remove key access at all. Owners and admins can see the whole picture at once on the Organization map (Company, then Org map): every member, team, vault, wallet and API key, with a line for each team membership, key-access grant and API-key wallet grant. It is a dashboard view for the people who manage access, not an API-key endpoint. Copies are removed explicitly per vault, and automatically in two cases: removing a member from the company deletes all of their grants, and changing a member's role to one that cannot hold a key (member or viewer) does the same. A member who is later promoted again starts with no vault access and must be re-granted, vault by vault. The last copy of a key cannot be removed, since nobody could sign for the vault again. Every offer, acceptance, decline, removal and secret read is recorded in the company activity log. A share is also refused when it would be useless: a signer who cannot reach any of the vault's wallets through a team cannot be handed its phrase. Add them to a team that holds one first. ### Teams and visibility A team holds a set of wallets, and its members see and act on exactly those. Every company has one default team holding every member, so on the Developer, Startup and Pro plans every member sees every wallet. The Enterprise plan unlocks additional teams, moving people and wallets between them, and the `signer` role. - Teams and their membership are managed by owners and admins (`/teams`). Owners and admins are never team-scoped: they see every wallet. - A wallet's teams are set when it is created and changed with `PATCH /wallets/:id`. A wallet on no team is visible to owners and admins only. - One wallet can belong to several teams, and a vault's wallets can be split across teams — custody is shared, access is not. - A team that still has members or wallets cannot be deleted, and the default team can never be deleted. - Teams change what members can see, not what they can spend. Sending stays role plus key access on top. - Dropping off Enterprise never widens access: the teams you already have keep scoping their members, and only the writes that would widen access are refused. ### API keys and roles An API key has no role of its own. It runs as the member who created it and can only be granted actions that member can perform at that moment (see Permissions & scoping). Owners, admins and members can create keys. The usual setup is a member creating a key for an integration: it can read, generate deposit addresses and request withdrawals, but it cannot send one, because a member holds no vault key. A key that sends needs an owner, admin or signer to create it, with a key-access grant on that vault. If the creator later loses the role, the grant, or their team access, the key loses the ability with them. A key belongs to whoever created it: you manage your own, and owners and admins manage every key in the company, so a credential can still be retired when the person who made it has gone. Another member's key is not yours to edit or revoke. Source: https://guveno.com/docs#members --- ## Permissions & scoping A key holds an explicit list of allowed actions: wallet-scoped actions granted per wallet, plus a couple of global actions. The key can do exactly what you granted it, on exactly the wallets you chose. ### Actions | Action | Scope | Allows | | --- | --- | --- | | `wallets:read` | Wallet | See a wallet and list its addresses. | | `balances:read` | Wallet | Read a wallet’s balances. | | `transactions:read` | Wallet | Read a wallet’s deposits and withdrawals, and set review marks on them. | | `withdrawals:read` | Wallet | List and read a wallet’s withdrawals. | | `wallets:update` | Wallet | Rename a wallet and manage its addresses. | | `wallets:delete` | Wallet | Archive/delete a wallet. | | `wallets:generate_address 🔑` | Wallet | Add new addresses to a wallet: derive them from the wallet secret, or have the server derive the next one when the wallet has an account xpub. | | `withdrawals:request` | Wallet | Propose a withdrawal from a wallet for a person to approve. Moves nothing on its own, so it needs no key of its own either. | | `withdrawals:create 🔑` | Wallet | Prepare and broadcast withdrawals from a wallet. | | `wallets:create` | Global | Create new wallets and vaults. | | `webhooks:manage` | Global | Create, rotate, and delete webhooks. | ### Per-wallet vs global Wallet-scoped actions are granted on specific wallets: a key can hold `withdrawals:create` on one wallet and only `balances:read` on another, and is invisible to every wallet you didn't grant. The two global actions (`wallets:create`, `webhooks:manage`) aren't tied to a wallet. ### Signing actions `withdrawals:request` is not one of them, deliberately: asking for a payout moves nothing, so a key that only proposes needs no access to the wallet's key and can be granted by a member who holds none. That is the pair to reach for when an integration should be able to start a payout but never send one. The 🔑 actions (`withdrawals:create`, `wallets:generate_address`) need the wallet's signing key. You can only grant them on wallets whose key you yourself hold, and a key that holds one of them can reveal that wallet's encrypted secret (`GET /wallets/:id/secret`, audited) so the SDK/CLI can sign or derive locally. The key acts as its creator, so if that member later loses access to the wallet's key, the key loses it too. ### Least privilege Grant the fewest wallets and actions that do the job: a key that only reads balances should hold just` balances:read` on the one wallet it watches. You can never grant an action you don't hold yourself, and every key change is audited in the company activity log. Source: https://guveno.com/docs#roles --- ## Vaults A vault is one recovery phrase and everything derived from it: its chain wallets, its team scope, and the members who hold a sealed copy of the phrase. ### The vault object ``` { "id": "3e2b7c8a-0f65-4f7f-9a83-2e33cbe0d1a4", "name": "Treasury", "fingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "status": "active", // active | archived "access": "full", // full = the caller holds a sealed copy of the phrase; watch_only otherwise "walletCount": 3, // active wallets in the vault "archivedAt": null, "createdAt": "2026-09-01T09:12:44.000Z", "updatedAt": "2026-09-01T09:12:44.000Z" } ``` `fingerprint` is a hash of the recovery phrase, so two systems can agree they mean the same seed without exchanging it. `access` is computed per caller: an API key reads `full` when the member who created it holds the vault; members and viewers always read `watch_only` because they can never hold a vault's key. A vault decides who can sign, not who can see: its wallets can belong to different teams, and a vault is visible to you when it holds a wallet you can reach, or when you hold its key. ### List vaults **GET** `/vaults` Returns the vaults the caller can see. An API key sees the vaults of the wallets it was granted `wallets:read` on. Query param: `status` (`active`, the default; `archived`; or `all`). ``` curl https://api.guveno.com/v1/vaults -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "id": "3e2b7c8a-...", "name": "Treasury", "walletCount": 3, ... } ] } ``` To list the wallets inside one vault, filter the wallet list: `GET /wallets?vaultId=…`. ### Get a vault **GET** `/vaults/:id` ``` curl https://api.guveno.com/v1/vaults/3e2b7c8a-0f65-4f7f-9a83-2e33cbe0d1a4 -H "Authorization: Bearer gv_live_your_key_here" ``` ### Create a vault **POST** `/vaults` Registers a new recovery phrase as an empty vault. You send the phrase's fingerprint and your own sealed copy of it (`encryptedSecretJson`, encrypted to your public key on your device); Guveno never receives the phrase itself. Wallets are then added with `POST /wallets` and `vaultId`. Most integrations skip this step: creating a wallet with a fingerprint creates its vault on the fly. Requires the `wallets:create` action. ``` curl -X POST https://api.guveno.com/v1/vaults -H "Authorization: Bearer gv_live_your_key_here" -H "Content-Type: application/json" -d '{ "name": "Treasury", "keyFingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "encryptedSecretJson": { "version": 2, "type": "x25519-sealed-box", "ciphertext": "..." } }' ``` A fingerprint already registered in your company is rejected with `409`: a phrase backs exactly one vault. Vault names are unique per company. ### Rename, archive **PATCH** `/vaults/:id` **DELETE** `/vaults/:id` `PATCH` accepts `name`. Who can see a vault's wallets is set per wallet (`PATCH /wallets/:id` with `teamIds`), not here — custody and access are separate controls.`DELETE` archives the vault and is refused with `409` while it still has active wallets or any address holding a balance; archiving keeps the key-access records so the history stays auditable. Both are available to dashboard sessions only, not to API keys. ### Who holds the key Who holds a copy of a vault's phrase is managed in the dashboard, under the vault's key holders page, and has no API. A copy is the phrase itself: it is produced by decrypting the sharer's own copy in their browser and re-encrypting it to the recipient, so only someone who already holds the key can pass it on. It is then an *offer* — the recipient accepts it before it becomes theirs, so nobody is made responsible for a vault without saying yes. Every step re-authenticates and emails the people who hold that key. Read **Roles and teams** for who may hold one. Source: https://guveno.com/docs#vaults --- ## Wallets A wallet is one chain and network inside a vault. It groups the on-chain addresses Guveno watches for you, all derived from the vault's recovery phrase at one derivation path. ### List wallets **GET** `/wallets` Query params: `limit`, `cursor`, `chain`, `network`, `name`, `vaultId`, `ids`. The `name` filter is an exact, case-sensitive match; since names are unique per chain and network, `name` + `chain` + `network` returns at most one wallet. This is the intended way to look a wallet up by name. `vaultId` returns the wallets of one vault. `ids` takes a comma-separated list and returns just those wallets — for when you already hold a set of ids and want their current rows in one request instead of paging the whole list. At most 100 per call; ask for more and the request is refused rather than answered in part. Scoping is unchanged, so an id your key cannot see simply does not come back: a short result means “not visible to this key”, never “deleted”. ``` curl "https://api.guveno.com/v1/wallets?chain=ethereum&limit=20" \ -H "Authorization: Bearer gv_live_your_key_here" # Look up one wallet by name curl "https://api.guveno.com/v1/wallets?name=treasury-eth&chain=ethereum&network=mainnet" \ -H "Authorization: Bearer gv_live_your_key_here" # Resolve a set of ids you already hold curl "https://api.guveno.com/v1/wallets?ids=12,7,3" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "id": 12, "vaultId": "3e2b7c8a-0f65-4f7f-9a83-2e33cbe0d1a4", "vaultName": "Treasury", "keyId": "3e2b7c8a-0f65-4f7f-9a83-2e33cbe0d1a4", // same as vaultId (historical name) "teams": [{ "id": 4, "name": "Treasury" }], // who can see and use this wallet; [] = owners/admins only "name": "treasury-eth", "chain": "ethereum", "network": "mainnet", "derivationPathPrefix": "m/44'/60'/0'/0", "autoWithdrawal": true, // false = withdrawals must name the source address(es) "status": "active", "chainInPlan": true, // false = the chain left your plan; chain-using calls 403 "addressCount": 4, "balances": [ // aggregated holdings per asset (list view only) { "asset": { "symbol": "ETH", ... }, "total": "18.42" } ] } ], "pageInfo": { "nextCursor": null } } ``` Each wallet in the list carries `balances` (its holdings summed per asset across all addresses) so you can show a portfolio figure without a request per wallet. Combine it with `GET /prices` (below) for an estimated USD value. Testnet wallets are worth nothing, so price them at 0. `chainInPlan` reports whether the wallet's chain and network are still included in your organisation's plan. It turns `false` when a chain add-on is removed or the plan is downgraded: the wallet, its balances, and its history stay fully readable, but withdrawals, registering new addresses, and manual sync return `403` until the chain is back on the plan, at which point the block lifts immediately. Check it before preparing a withdrawal. ### Get a wallet **GET** `/wallets/:id` The detail adds `key` (the vault: `id`, `name`, `fingerprint`), `nextAccountIndex` (the next address index to derive), and a `keyAccess` grant when the caller holds access to the vault, but only its metadata (`id`, `keyId`, `userId`, timestamps). The encrypted secret is never returned here; fetch it from the dedicated endpoint below. ### Reveal the key secret **GET** `/wallets/:id/secret` The SDK and CLI call this for you when they derive an address or sign a withdrawal, and that is how it is meant to be used. It returns the caller's `keyAccess` grant including `encryptedSecretJson`: the wallet secret encrypted to your own public key, decryptable only on your device. Reach for it directly only if you are implementing signing yourself — unsealing and deriving by hand is easy to get wrong, and the mistakes cost funds rather than raising errors. An API key may read it, acting as the member who created the key, provided that member holds a copy of the vault's phrase (viewers can never hold one). Each successful read is recorded in the activity log as a secret-access event. Handing a copy to *someone else* is not done here, or anywhere in the API: see **Who holds the key**. ### Addresses **GET** `/wallets/:id/addresses` Query params: `limit`, `cursor`, `status` (`active`, the default; `archived`, or`all`) and `search` (matches the address or its label). **POST** `/wallets/:id/addresses/:addressId/unarchive` Restore a previously archived address. Requires an owner, admin or manager role. ### Server-side address derivation **POST** `/wallets/:id/addresses` Derives and registers a receive address on the wallet and returns it, using only the API key — no encryption password, no key material at the caller. This is the endpoint to reach for when a platform needs a deposit address per customer on demand. It works because the wallet can register an **account xpub**: the extended **public** key at its `derivationPathPrefix`. Every chain except Polkadot derives addresses on a BIP44 path whose tail is non-hardened, so that public key reproduces exactly this wallet's addresses — and cannot sign, authorize a withdrawal, or reveal the phrase it came from. Spending authority is unchanged: withdrawals are still prepared and signed on your side. We hold no key material, before or after. Register it with `accountXpub`, on `POST /wallets` at creation or `PATCH /wallets/:id` later, computed where the seed is — `accountXpubFromMnemonic()` in the Node SDK, `account_xpub_from_mnemonic()` in the Python SDK, or `guveno enable-derivation ` in the CLI. Before storing it we re-derive the addresses already on the wallet and require every one to match; a single mismatch rejects the request and nothing is stored. An extended *private* key is rejected outright rather than neutered for you. Send `null` to turn it back off. In the dashboard it is a switch: **Wallet → Settings → Address generation**. Turning it on asks for your encryption password, computes the account xpub in your browser and registers it — the same request, with the phrase never leaving your device. The card names the wallet's current state, and turning it off drops the stored key. Send no body and the server owns the index: two concurrent calls return two consecutive addresses rather than racing for one, so the same address can never be handed to two customers. Wallets report `serverDerivation` so you can tell which are enabled; the xpub itself is never returned. Send `accountIndex` and you own it instead. The use for it is giving one customer the same address on two wallets that share a vault and a derivation prefix, and so an account xpub — the same index derives the same address on each, which is how an EVM deposit address can be identical across chains. An index already registered on that wallet is refused with `409`, archived addresses included: nothing is reassigned and no free index is substituted, because the address sitting on a taken index may already belong to somebody else. Allocation stays highest + 1, so pinning a high index moves that wallet's next automatic index above it. Valid indexes run from `0` to `2147483647`, BIP32's non-hardened ceiling. ``` curl -X POST https://api.guveno.com/v1/wallets/42/addresses \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"label": "customer-4471", "accountIndex": 41}' ``` `409` when the wallet has not opted in, when the requested index is taken, and always for Polkadot, whose `//i` junctions are hard derivation that no public key can perform — those wallets keep deriving client-side. Generating requires the `wallets:generate_address` action; registering the xpub requires `wallets:update`, deliberately not the same one, so a key that may use server derivation cannot also enable it. ### Creating wallets **POST** `/wallets` **PATCH** `/wallets/:id` **DELETE** `/wallets/:id` Creating a wallet involves deriving addresses and encrypting key material on your side. The simplest path is the Node.js or Python SDK, which builds the request payload for you. Every wallet belongs to a vault, and the body names the vault in one of two ways: - **An existing vault**: send `vaultId`. You must already hold key access to that vault (otherwise `403`), and no seed material travels with the request. Use this to add another chain, or another derivation path on the same chain, to a vault you hold. - **A recovery phrase**: send `keyFingerprint` and `encryptedSecretJson`. If a vault already owns that fingerprint (and you hold access to it), the wallet joins it; otherwise a new vault is created, named `vaultName` if you give one and after the wallet if not. ``` # Add an Ethereum wallet to a vault you already hold curl -X POST https://api.guveno.com/v1/wallets -H "Authorization: Bearer gv_live_your_key_here" -H "Content-Type: application/json" -d '{ "name": "treasury-eth", "chain": "ethereum", "network": "mainnet", "vaultId": "3e2b7c8a-0f65-4f7f-9a83-2e33cbe0d1a4", "addresses": [ { "address": "0xabc...", "derivationPath": "m/44'''/60'''/0'''/0/0", "accountIndex": 0, "label": "main" } ] }' # Register a wallet from a recovery phrase (creates the vault if the fingerprint is new) curl -X POST https://api.guveno.com/v1/wallets -H "Authorization: Bearer gv_live_your_key_here" -H "Content-Type: application/json" -d '{ "name": "treasury-btc", "chain": "bitcoin", "network": "mainnet", "vaultName": "Treasury", "keyFingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "encryptedSecretJson": { "version": 2, "type": "x25519-sealed-box", "ciphertext": "..." }, "addresses": [ { "address": "bc1q...", "derivationPath": "m/84'''/0'''/0'''/0/0", "accountIndex": 0 } ] }' ``` Sending `vaultId` together with `keyFingerprint`, `encryptedSecretJson` or `vaultName` is a `400`: the existing vault already defines them. `teamIds` decides who can see the wallet and is accepted either way; omit it on a plan without teams and the wallet joins your company's default team, which holds every member. Change it later with `PATCH /wallets/:id` (owners and admins, dashboard sessions only). Wallet names are unique per chain and network within your company. Deleting a wallet archives it; the vault stays until you archive it too. Address updates via `PATCH` are additive: the `addresses` you send are created or updated, and existing addresses are left untouched (never archived by omission). An address that holds a balance can't be archived, and deleting a wallet is rejected while any of its addresses still hold funds. Registering an address this way says you derived it from the vault's seed, and the server holds you to that. The caller must hold key access to the wallet's vault, whatever else the body carries (an API key is held to its owner's access); a role alone is not enough and gets a `403`. Every address has to be a well-formed address for the wallet's chain and network — checksum, encoding and network prefix are checked, and a typo or an address pasted from another chain is a `400`. And on a wallet with server-side derivation enabled, each address must be the one the registered account xpub derives at that index; anything else is refused, and one bad address rejects the whole batch. The SDKs and the dashboard derive addresses that pass all three by construction. `autoWithdrawal` (boolean, default `true`) controls how withdrawals choose a source. When `true`, a withdrawal that supplies only `walletId` lets the server auto-pick which address(es) (and, for Bitcoin, which UTXOs) to draw from. Set it to `false` (on `POST /wallets` or later via `PATCH /wallets/:id`) to keep a wallet's funds segregated: withdrawals must then name the source explicitly with `addressId` (or `sourceAddressIds` for Bitcoin), and an auto `walletId`-only prepare is rejected. ### Derivation paths Each wallet carries `derivationPathPrefix`, the account-level path its addresses hang off. Every address you register must sit at exactly `/` (Polkadot: ``); anything else is a `400`. Omit the prefix on create to get the chain standard: | Chain | Default prefix | | --- | --- | | Ethereum, BNB Smart Chain | `m/44'/60'/0'/0` | | Bitcoin mainnet | `m/84'/0'/0'/0` | | Bitcoin testnet | `m/84'/1'/0'/0` | | XRP | `m/44'/144'/0'/0` | | TRON | `m/44'/195'/0'/0` | | Polkadot | `// (hard junctions, e.g. //0)` | A vault holds at most one wallet per chain, network and prefix; a second wallet at the same prefix would derive the same addresses, so it is refused with `409`. To hold two wallets on one chain in the same vault, give the second one a different prefix (for example `m/44'/60'/1'/0`, the next BIP44 account). Prefixes are validated against a strict grammar: BIP32 paths use `m/` and `'` for hardened segments only; Polkadot prefixes are `//` or a chain of hard junctions ending in `//`, such as `//treasury//`. A non-standard prefix will not be found by ordinary wallet software during recovery, so record it with your backups. Finally, one on-chain address belongs to exactly one wallet in your company; registering it under a second wallet is a `409`. Source: https://guveno.com/docs#wallets --- ## Assets Assets are the coins and tokens Guveno tracks. Balances and transactions reference an asset by its symbol; this catalog tells you the chain, type and decimals behind it. ### List assets **GET** `/assets` Filter with `chain`, `network`, `type` (`native` or `erc20`) and `enabled`. ``` curl "https://api.guveno.com/v1/assets?chain=ethereum&type=erc20" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Get an asset **GET** `/assets/:id` ### Asset object ``` { "id": 3, "key": "ethereum:mainnet:erc20:usdt", // stable unique key "chain": "ethereum", // ethereum | bsc | bitcoin | xrp | polkadot | tron "network": "mainnet", "type": "erc20", // native | erc20 | trc20 "symbol": "USDT", "name": "Tether USD", "decimals": 6, "contractAddress": "0xdAC17F95...", // null for native assets "enabled": true } ``` Amounts elsewhere in the API are decimal strings already scaled to whole units (not base units), so you rarely need `decimals` for display; it's there for on-chain conversions. Source: https://guveno.com/docs#assets --- ## Balances Read balances per address, per wallet, or across the whole company. ### Per address **GET** `/wallets/:id/balances` ### Wallet totals **GET** `/wallets/:id/balances/total` **GET** `/wallets/:id/stats` ### Company summary **GET** `/company/balances/summary` Query params: `chain`, `network`. ``` curl https://api.guveno.com/v1/company/balances/summary \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "asset": "ETH", "chain": "ethereum", "network": "mainnet", "amount": "18.42" }, { "asset": "USDT", "chain": "ethereum", "network": "mainnet", "amount": "120500.00" } ] } ``` ### Estimated value **GET** `/prices` Rough USD price per whole unit for the supported mainnet assets, sourced from an external market feed and cached server-side for a few minutes. Multiply a balance by its symbol's price to get an estimated value. A symbol is omitted when the source didn't return it, and `updatedAt` is `null` when prices are momentarily unavailable. ``` curl https://api.guveno.com/v1/prices \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "prices": { "BTC": 64210.5, "ETH": 3380.2, "XRP": 0.51, "DOT": 6.9 }, "updatedAt": "2026-06-25T12:00:00.000Z" } ``` These are estimates, not settlement rates. Testnet assets (Sepolia, Bitcoin/XRP testnet, Paseo) have no monetary value and are not priced, so treat them as 0. Source: https://guveno.com/docs#balances --- ## Transactions Query confirmed and pending deposits and withdrawals, and annotate them for review. ### List transactions **GET** `/transactions` Returns newest-first. For Bitcoin, each item also carries the full `inputs` and `outputs` arrays (every source and destination address with its amount) when the upstream provider supplies them. XRP transactions carry `destinationTag`: for a deposit, the tag the payment was sent with; for a withdrawal, the tag it was sent to. It is `null` when there was none, and on every other chain. ``` curl "https://api.guveno.com/v1/transactions?walletId=12&direction=incoming&limit=50" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "id": 9001, "direction": "incoming", "status": "confirmed", "txHash": "0xabc...", "amount": "1.5", "asset": "ETH", "confirmedAt": "2026-06-14T11:55:00.000Z" } ], "pageInfo": { "nextCursor": "eyJpZCI6OTAwMX0" } } ``` ### Filtering All filters are optional and combine (AND): - `walletId`, `addressId`: scope to one wallet or address. - `chain`, `network`: e.g. `ethereum` / `mainnet`. - `direction`: `incoming` or `outgoing`. - `status`: `pending`, `confirmed` or `rejected`. - `asset`: asset symbol (e.g. `ETH`, `BTC`); matches any movement. - `search`: substring match against the tx hash or any from/to address. - `from`, `to`: ISO-8601 date-time bounds on when the tx was first recorded. - `amountMin`, `amountMax`: decimal amount bounds; matches any movement. - `marked`: restrict to a review list — `saved`, `flagged`, `flagged_open`, `flagged_resolved` or `assigned` (see **Review marks**). ``` curl "https://api.guveno.com/v1/transactions?chain=bitcoin&status=confirmed\ &from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z\ &amountMin=0.01&search=bc1q" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Pagination Pass the returned `pageInfo.nextCursor` back as `cursor` to fetch the next page. A null cursor means you've reached the end. ### Get one transaction **GET** `/transactions/:id` Returns a single transaction in the same shape as a list item. Useful for following a link to a transaction someone flagged, without paging the whole feed. ``` curl "https://api.guveno.com/v1/transactions/9001" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Review marks Every transaction carries a `marks` object — the review layer your team uses in the dashboard. There are two marks, and they differ in who can see them: - **★ `saved`** — **personal**. Your own "review later" queue. Nobody else's saved state is ever reported to you, and yours is never reported to them. - **⚑ `flagged`**, **`note`** and the **assignee** — **shared**, with the author attached. A flag says "this needs attention", so everyone who can already see the transaction sees who raised it and why. `saved`, `flagged` and `note` at the top level are **yours**; `shared` lists every member's flag and note with its author. Marks are organizational only — they never affect crediting, confirmations or balances. A flag has a **lifecycle**, which is what makes the flagged list a queue rather than a pile. It is raised, then either **resolved** (reviewed and closed — `resolvedAt` and `resolvedBy`) or withdrawn (`flagged: false`, as if it had never been raised). It may also name one member it is waiting on (`assignedTo`), who is notified once. `flagCount` counts every flag; `openFlagCount` counts the ones still waiting on a review. ``` { "saved": true, "flagged": false, "flaggedAt": null, "note": null, "noteUpdatedAt": null, "resolvedAt": null, "resolvedBy": null, "assignedTo": null, "assignedAt": null, "flagCount": 1, "openFlagCount": 1, "shared": [ { "user": { "userId": 3, "email": "dana@acme.com", "firstName": "Dana", "lastName": null }, "flagged": true, "flaggedAt": "2026-06-14T12:00:00.000Z", "note": "waiting on counterparty confirmation", "noteUpdatedAt": "2026-06-14T12:05:00.000Z", "resolvedAt": null, "resolvedBy": null, "assignedTo": { "userId": 8, "email": "rafa@acme.com", "firstName": "Rafa", "lastName": null }, "assignedAt": "2026-06-14T12:06:00.000Z" } ] } ``` **PUT** `/transactions/:id/marks` Sets **your own** marks (an API key acts as its creator). Only the fields you send change, so the ★, the ⚑ and the note can each be written on their own. Send `""` or `null` as the note to clear it; the row is removed once no mark is left set. Notes are capped at 1000 characters. ``` curl -X PUT "https://api.guveno.com/v1/transactions/9001/marks" \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"flagged": true, "note": "unexpected fee, review with finance"}' ``` `assignToUserId` names the one member the flag is waiting on. They are told once, in the dashboard and by email, so it requires a raised flag and a member who can **already see** the transaction — anyone else is refused. Send `null` to clear it. A flag with nobody named notifies nobody; it shows up in the flagged list and the activity log instead. ``` curl -X PUT "https://api.guveno.com/v1/transactions/9001/marks" \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"flagged": true, "assignToUserId": 8}' ``` **GET** `/transactions/:id/marks` Reads the same object without fetching the whole transaction. **GET** `/transactions/marks/summary` Counts for the review lists: `saved` is how many you saved, `flagged` how many are flagged by anyone whose visibility you share, `flaggedOpen` how many of those are still waiting on a review, and `assignedToMe` the open flags someone asked you to look at. ``` curl "https://api.guveno.com/v1/transactions/marks/summary" \ -H "Authorization: Bearer gv_live_your_key_here" { "saved": 3, "flagged": 5, "flaggedOpen": 2, "assignedToMe": 1 } ``` To page through a review list, use the `marked` filter on the list endpoint — it accepts every other filter too: ``` curl "https://api.guveno.com/v1/transactions?marked=flagged_open&limit=50" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Resolving a flag **POST** `/transactions/:id/marks/resolve` Closes a flag as reviewed. This is a separate call from setting your own marks because the person who reviews a flag is usually **not** the one who raised it: pass `raisedByUserId` to close someone else's flag, or omit it for your own. The flag keeps naming who raised it and records who reviewed it, so "has anyone looked at this" has an answer. ``` curl -X POST "https://api.guveno.com/v1/transactions/9001/marks/resolve" \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{"raisedByUserId": 3}' ``` Send `{"resolved": false}` to put a resolved flag back in the queue. Resolving one that is already resolved changes nothing and notifies nobody a second time, so a retry is safe. Withdrawing a flag (`flagged: false`) clears its resolution and assignee with it — there is no such thing as a resolved flag that was never raised. > Reading and writing marks — including resolving someone else's flag — needs only `transactions:read`. A mark moves no money and changes no chain record, and the roles that most want to flag and review things (reviewers, accountants) are read-only by design. Source: https://guveno.com/docs#transactions --- ## Withdrawals Withdrawals are a three-step flow: prepare an unsigned transaction, sign it locally with your key material, then broadcast it. > Send an `Idempotency-Key` on both calls. If a broadcast fails in a way that leaves the outcome unknown, resend the **identical** `signedRawTx` — never a newly signed one. Signing never happens on Guveno: the `signingPayload` you get from `prepare` is signed in your environment (usually by the SDK or CLI), then broadcast. ### Prepare **POST** `/withdrawals/prepare` Returns the withdrawal record plus a `signingPayload` for you to sign offline / client-side. Choose the source with **either** `walletId` **or** `addressId` (exactly one): - `walletId`: **auto-select**. The server picks a source with enough available balance. For account chains (Ethereum, BNB Smart Chain, XRP, Polkadot, TRON) it picks one address whose balance covers the amount; for Bitcoin it **aggregates UTXOs across the wallet** and returns change to a wallet-owned address. - `addressId`: **advanced**. Send from one specific address. (Bitcoin can instead pass `sourceAddressIds`, an array, to restrict aggregation to a chosen subset of the wallet's addresses.) Because account-based chains can't combine balances in a single transaction, an auto withdrawal fails with `422` if no single address covers the amount. Auto-select also requires the wallet's `autoWithdrawal` setting to be on (the default). If a wallet has `autoWithdrawal: false` (see `POST`/`PATCH /wallets`), a `walletId`-only prepare is rejected with `400`: you must name the source explicitly with `addressId` (or `sourceAddressIds` for Bitcoin) so funds stay segregated per address. ``` # Auto-select (recommended): let the server pick the source curl https://api.guveno.com/v1/withdrawals/prepare \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Idempotency-Key: 8f1c-prepare-001" \ -H "Content-Type: application/json" \ -d '{ "walletId": 12, "assetId": 3, "amount": "0.25", "toAddress": "0xRecipient..." }' # Advanced: send from a specific address curl https://api.guveno.com/v1/withdrawals/prepare \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Idempotency-Key: 8f1c-prepare-002" \ -H "Content-Type: application/json" \ -d '{ "addressId": 50, "assetId": 3, "amount": "0.25", "toAddress": "0xRecipient..." }' ``` **XRP destination tags**: pass `destinationTag`, an integer from `0` to `4294967295`, when the recipient needs one (exchanges usually do). It is carried into the signed Payment, and a signed transaction whose tag does not match is refused at broadcast. It is accepted only on XRP: sending one on any other chain is a `400`, since nothing there could carry it. ``` curl https://api.guveno.com/v1/withdrawals/prepare \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Idempotency-Key: 8f1c-prepare-003" \ -H "Content-Type: application/json" \ -d '{ "walletId": 14, "assetId": 5, "amount": "25", "toAddress": "rRecipient...", "destinationTag": 1433698153 }' ``` ### Broadcast **POST** `/withdrawals` Submit the signed transaction to broadcast it on-chain. ``` curl https://api.guveno.com/v1/withdrawals \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Idempotency-Key: 8f1c-broadcast-001" \ -H "Content-Type: application/json" \ -d '{ "withdrawalId": 7, "signedRawTx": "0x02f8..." }' ``` Guveno records your signed transaction, and the hash it will have, **before** handing it to a node, and submits it exactly once. That record is what makes the failure cases below safe. ### When a send’s outcome is unknown A broadcast is the one call in the API whose failure does not mean “nothing happened”. If a node accepted the transaction and the reply was lost, the payment is on the network even though your request failed. Guveno reports that case as `502`, with the committed `txHash` in the body: ``` { "statusCode": 502, "message": "The network did not confirm whether this transaction was accepted. ...", "txHash": "0x9a1f..." } ``` Two safe ways to resolve it, and one dangerous one: - **Look up the hash on-chain.** If the transaction is there, the withdrawal settles by itself and `GET /withdrawals/:id` will move to `broadcast` then `confirmed`. - **Resend the identical bytes.** Call `POST /withdrawals` again with exactly the same `signedRawTx`. The same bytes are the same transaction, so this is not a second payment. - **Never sign a replacement.** On an account chain a new signature means a new nonce, so if the first transaction did land, the recipient is paid twice. The API enforces the last point rather than trusting callers. While a send is unresolved: `POST /withdrawals` returns `409` for any `signedRawTx` other than the one it already holds; replaying `POST /withdrawals/prepare` with that withdrawal's idempotency key returns `409` instead of issuing a fresh signing payload; and preparing **any new withdrawal from the same address** returns `409` until the outstanding one is reconciled. Contact support if a transaction never arrives. A `400` from a broadcast is different: it means the node refused the transaction on its merits (most often the source can't fund gas). Nothing was sent, the withdrawal stays `prepared`, and you can fix the problem and broadcast again. ### Idempotency Both calls require an `Idempotency-Key` header. Reusing the same key returns the original result instead of creating a duplicate. For `prepare` that makes a retry safe on any error. For `broadcast` the key alone is not enough — reuse the key **and** the same `signedRawTx`, as above. Both endpoints need `withdrawals:create` granted on the source wallet, and the member behind the key needs a company role that can withdraw (`signer` or above). ### Signing & API keys An API key can `prepare` and `broadcast` withdrawals, but it **cannot sign them**. Signing uses the wallet's private key, which lives only with you. Guveno never holds spendable key material, and the endpoints that handle encrypted secrets reject API keys outright. So the middle step (turning the `signingPayload` into `signedRawTx`) runs in your own environment, typically via the SDK or CLI which hold the mnemonic. A fully unattended signer therefore needs your key material on the machine that calls the API, so plan your custody accordingly. ### List **GET** `/withdrawals` **GET** `/withdrawals/:id` Returns newest-first. Optional filters combine (AND): `walletId`, `addressId`, `chain`, `network` and `status` (`prepared`, `broadcast`, `confirmed` or `rejected`). Set the page size with `limit` (default 100, max 200) and pass the returned `pageInfo.nextCursor` back as `cursor` to fetch the next page. A null cursor means you've reached the end. ``` curl "https://api.guveno.com/v1/withdrawals?walletId=12&status=broadcast&limit=50" \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "id": 7, "addressId": 50, "assetId": 3, "network": "mainnet", "toAddress": "0xRecipient...", "amount": "0.25", "status": "broadcast", "txHash": "0x02f8...", "createdAt": "2026-06-14T11:55:00.000Z" } ], "pageInfo": { "nextCursor": "eyJpZCI6N30", "hasNextPage": true } } ``` Source: https://guveno.com/docs#withdrawals --- ## Webhooks Subscribe to deposit and withdrawal events and receive them at your own endpoint. > Three rules keep webhook money safe: credit a balance only on `deposit.confirmed` (never `deposit.detected`), verify the `x-guveno-signature` header before trusting a payload, and dedupe on the envelope `id`, because deliveries can be retried. ### Withdrawal requests — payouts a person approves **POST** `/withdrawal-requests` **GET** `/withdrawal-requests` **GET** `/withdrawal-requests/:id` **POST** `/withdrawal-requests/:id/decline` The flow above assumes the caller can sign. A withdrawal **request** is for the case where it cannot, and shouldn't: an integration proposes a payout, and a person reviews and signs it. Creating a request moves no funds, signs nothing, and authorizes nothing — it records an intent. A leaked API key gets you a proposal somebody still has to approve. The key needs `withdrawals:request` on the wallet — not `withdrawals:create`, which is the signing action and is what the approver spends. A key holding only `withdrawals:create` cannot propose, and one holding only `withdrawals:request` can never send; that split is the point. Two separations hold, and both are enforced server-side. **An API key can never decide**: approving and declining require an interactive session. **An approver must be able to sign** — they need key access on the wallet, checked before anything is reserved. On top of that, a request a *person* proposed needs a different person to approve it; one an API key proposed may be approved by the key's own member, since that member could withdraw directly anyway and requiring a second human would lock a one-person company out of the flow entirely. ``` curl -X POST "https://api.guveno.com/v1/withdrawal-requests" \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "walletId": 12, "assetId": 3, "toAddress": "0xRecipient...", "amount": "1.5", "idempotencyKey": "exchange-withdrawal-991", "reference": "exchange tx 991", "approverUserId": 5 }' ``` Idempotent on `idempotencyKey`: a repeat with the same key returns the original request, so a caller that already debited a user and then lost the response can retry without proposing a second payout. The same key with different parameters is a `409`, never a silent overwrite. `approverUserId` asks a specific member to review it — they are emailed and notified, and the request shows as waiting on them. It is advisory: any other authorized member can still approve, so an approver who is away cannot strand a payout. `expiresInSeconds` runs from 5 minutes to 30 days (7 days by default); an unanswered request expires rather than lingering as a standing authorization to move funds. On XRP, `destinationTag` (an integer from `0` to `4294967295`) is kept on the request exactly as proposed, and it is what the approver signs. It is refused on any other chain. **Approving is not an endpoint here.** It is `POST /withdrawals/prepare` with `withdrawalRequestId` and nothing else — the destination, amount and asset are read from the stored request, so the approver signs exactly what was reviewed, and supplying any of them alongside is a `400` rather than a silent merge. Sign the returned payload and send it to `POST /withdrawals`; the request becomes `approved` once that broadcast succeeds. There is deliberately no route an API key alone can reach that moves funds. ``` curl -X POST "https://api.guveno.com/v1/withdrawals/prepare" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "withdrawalRequestId": 7, "idempotencyKey": "approve-7" }' ``` Poll `GET /withdrawal-requests/:id` for the outcome. `approved` carries `withdrawalId` — read that withdrawal for the hash and confirmations. `rejected` means a person declined it (`declineReason` says why) and `expired` means nobody acted in time; neither produces a withdrawal and neither is retried, so propose a new request if you still want the payout. Note that a decline emits no webhook — `withdrawal.broadcast` fires for an approval because a withdrawal exists, but a declined request never becomes one, so polling is how you learn about it. ### Integrating A webhook integration is a single HTTPS endpoint on your side that Guveno `POST`s to whenever something happens on one of your wallets. End to end: - **1. Register a receiver.** `POST /webhooks` with your URL and the events you want (see *Subscribing*). Keep the `signingSecret` from the response; it is shown only once and you need it to verify deliveries. - **2. Receive the delivery.** Each event arrives as a JSON `POST` with two headers that matter: `content-type: application/json` and `x-guveno-signature: sha256=`. - **3. Verify it is really us.** Recompute the HMAC over the *raw* request body and compare it to the header (see *Verifying signatures*). Reject anything that does not match. - **4. Deduplicate.** A delivery can be retried, so the same event may arrive more than once. The envelope `id` is stable across retries; treat it as an idempotency key and ignore an `id` you have already processed. - **5. Acknowledge fast.** Return a `2xx` as soon as you have persisted the event; do the slow work (crediting balances, notifying users) afterwards. Any non-`2xx` response (or a timeout) is treated as a failed delivery and retried. Retries use exponential backoff (≈5s, doubling, capped at 60s) for up to 5 attempts; a `429` from your endpoint is honored via its `Retry-After` header. After the final attempt the delivery is marked`failed` and you can inspect or replay it from the *Delivery logs*. Below is a complete delivery exactly as it lands on your endpoint: ``` POST /your-endpoint HTTP/1.1 Content-Type: application/json X-Guveno-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 { "id": "8f1c2e4a-7b3d-4c9a-9b1e-2a6f0d4e8c11", "type": "deposit.confirmed", "chain": "ethereum", "network": "mainnet", "companyId": 42, "createdAt": "2026-06-14T11:55:00.000Z", "data": { "chain": "ethereum", "network": "mainnet", "txHash": "0xabc...", "addressId": 50, "confirmations": 12, "status": "confirmed", "direction": "incoming", "amount": "1.5", "asset": { "key": "ethereum:mainnet:usdc", "symbol": "USDC", "decimals": 6, "contractAddress": "0xa0b8..." }, "fromAddress": "0x1111...", "toAddress": "0x2222...", "eventType": "erc20_transfer", "eventIndex": 0 } } ``` ### Events - `deposit.detected` - `deposit.confirmed` - `deposit.rejected` - `withdrawal.broadcast` - `withdrawal.confirmed` - `withdrawal.rejected` ### Event payloads Every delivery is a POST with the same envelope. `chain` and `network` are on the envelope so you can route by chain without parsing `data`; `data` varies by event type (and repeats `chain`/`network`): ``` { "id": "8f1c2e4a-...", // unique event id (also the idempotency key) "type": "deposit.confirmed", // one of the event types above "chain": "ethereum", // "ethereum" | "bsc" | "bitcoin" | "xrp" | "polkadot" | "tron" "network": "mainnet", // e.g. mainnet | sepolia | testnet | paseo | nile "companyId": 42, "createdAt": "2026-06-14T11:55:00.000Z", "data": { /* see below */ } } ``` **Deposit events**: `deposit.detected`, `deposit.confirmed`, `deposit.rejected`. Each carries the asset and amount that moved, so you can credit a balance straight from the delivery. A transaction that moves more than one asset (or pays multiple of your addresses) fans out into **one event per transfer**: dedupe on the envelope `id`, and use `txHash` + `eventIndex` to tell transfers of the same tx apart: ``` { "chain": "ethereum", "network": "mainnet", "txHash": "0xabc...", "addressId": 50, "confirmations": 12, "status": "confirmed", // "pending" | "confirmed" | "rejected" "direction": "incoming", "amount": "1.5", // exact string in display units — never a JSON number "asset": { "key": "ethereum:mainnet:usdc", "symbol": "USDC", "decimals": 6, "contractAddress": "0xa0b8..." // null for the chain's native asset }, "fromAddress": "0x1111...", // present when the chain exposes it "toAddress": "0x2222...", // your deposit address "eventType": "erc20_transfer", "eventIndex": 0, // stable ordinal within the tx "logIndex": 3 // EVM log index (or "vout" on UTXO chains), when applicable } ``` **Bitcoin only**: deposit events additionally carry the full transaction topology when the provider supplies it: `inputs` (every source address) and `outputs` (every destination), each `{ address, amount }`. Account-based chains omit these. **XRP only**: deposit events carry `destinationTag`, the tag the payment was sent with, or `null` when it had none. Use it to attribute a deposit to a customer when several share one address. For a tagged payment, `toAddress` also reads `address:tag`. ``` "chain": "xrp", "toAddress": "rYours...:1433698153", "destinationTag": 1433698153 // null when the payment carried no tag ``` ``` "inputs": [ { "address": "bc1qsource1...", "amount": "0.40000000" }, { "address": "bc1qsource2...", "amount": "0.10141692" } ], "outputs": [ { "address": "bc1qyours...", "amount": "0.01411692" }, { "address": "bc1qchange...", "amount": "0.48730000" } ] ``` **Bitcoin only**: `deposit.detected` can fire while the transaction is still in the mempool (`status: "pending"`, `confirmations: 0`), before it is mined into a block, so you learn of an incoming payment as soon as it is broadcast. It is followed by `deposit.confirmed` once it reaches the confirmation threshold. If the mempool transaction is replaced or double-spent and never confirms, it is instead `deposit.rejected` with `reason: "mempool_dropped"`. Wait for `deposit.confirmed` before treating funds as final. **Withdrawal events**: `withdrawal.broadcast` fires the moment a signed tx hits the network (`status: "broadcast"`, no `confirmations`/`fee` yet); `withdrawal.confirmed` carries `confirmations` (and the network `fee` when known); `withdrawal.rejected` carries a `reason`. All three carry the asset, amount, source and destination: ``` { "chain": "ethereum", "network": "mainnet", "withdrawalId": 7, "txHash": "0xdef...", "status": "broadcast", // "broadcast" | "confirmed" | "rejected" "confirmations": 6, // confirmed only "amount": "10.0", // exact string in display units "asset": { "key": "ethereum:mainnet:usdc", "symbol": "USDC", "decimals": 6, "contractAddress": "0xa0b8..." // null for the chain's native asset }, "fromAddress": "0x9999...", // the sending wallet address "toAddress": "0x3333...", // present when the withdrawal targets a single address "outputs": [ // multi-recipient Bitcoin sends: the full recipient list { "toAddress": "bc1qa...", "amount": "0.25" }, { "toAddress": "bc1qb...", "amount": "0.10" } ], "fee": { // confirmed only, when the on-chain fee is known "amount": "0.00042", "asset": { "key": "ethereum:mainnet:eth", "symbol": "ETH", "decimals": 18, "contractAddress": null } } } // withdrawal.broadcast omits "confirmations" and "fee". // withdrawal.rejected omits "confirmations"/"fee" and adds: // "reason": "failed_on_chain" // one of: reorg | mempool_dropped | failed_on_chain | expired | provider_error ``` Use `id` to deduplicate: a delivery may be retried, and the same `id` means the same event. The payload carries the asset and amount directly; you can still look up full records via the Transactions and Withdrawals endpoints using the ids/hashes in `data`. ### Status values Two different things have a "status", and it helps to keep them apart: the **money** status inside`data.status` (what happened on-chain), and the **delivery** status of the webhook attempt itself (whether we reached your endpoint; see *Delivery logs*). **Deposit** `data.status`, carried by the matching `deposit.*` event: - `pending` (`deposit.detected`): we have seen the transaction but it is not yet final. On Bitcoin this can fire from the mempool with `confirmations: 0`, before it is mined. **Do not credit funds yet.** - `confirmed` (`deposit.confirmed`): the deposit reached the chain's confirmation threshold. This is the signal to credit a balance; `amount` is the exact value delivered to your address. - `rejected` (`deposit.rejected`): a previously detected deposit will not settle (e.g. `reason: "mempool_dropped"` after a replacement/double-spend). Reverse anything you provisionally credited. **Withdrawal** `data.status`, carried by the matching `withdrawal.*` event: - `broadcast` (`withdrawal.broadcast`): the signed transaction has been published to the network. No `confirmations` or `fee` yet; the send has happened and is irreversible. - `confirmed` (`withdrawal.confirmed`): the transaction is mined, executed successfully, and past the confirmation threshold. Carries `confirmations` and the network `fee` when known. - `rejected` (`withdrawal.rejected`): the send did not land, with a `reason` of `reorg`, `mempool_dropped`, `failed_on_chain`, `expired`, or `provider_error`. `confirmed` means the funds moved, not just that the transaction is in a block. A mined transaction can still have reverted, and on EVM chains a non-standard token contract can return failure without reverting at all — a successful receipt that transferred nothing. Guveno settles a token withdrawal only against a matching `Transfer` log (right contract, sender, recipient and exact amount), so a withdrawal with no such evidence stays `broadcast` rather than being reported as paid. Expect a confirmation to lag a block explorer slightly; never treat inclusion alone as settlement. ### Confirming a delivery There are two independent ways to be sure of what a delivery is telling you. Use the first always, and the second whenever a webhook drives money movement on your side. **1. Verify the signature (authenticity).** Every delivery is signed, so you can trust the payload without a round-trip: recompute the HMAC over the raw body and compare it to `x-guveno-signature` (see*Verifying signatures* below). A matching signature proves the event came from Guveno and was not tampered with; for most integrations a verified `deposit.confirmed` is enough to credit a balance. **2. Reconcile against the API (authority).** When you want a second, authoritative source (or you missed a delivery), read the record back from the API using the ids/hashes in `data`. The API is the source of truth; the webhook is a notification. This also lets you confirm status even if your endpoint was down when the event fired. ``` # Deposit: find the transaction by its on-chain hash (search matches the hash) curl "https://api.guveno.com/v1/transactions?search=0xabc..." \ -H "Authorization: Bearer gv_live_your_key_here" # Withdrawal: fetch the current state by id curl https://api.guveno.com/v1/withdrawals/7 \ -H "Authorization: Bearer gv_live_your_key_here" ``` To confirm whether *we* reached *you* (as opposed to what happened on-chain), check the delivery log for the webhook: its `status` is `pending`, `delivered`, or `failed`, with the receiver's `responseCode` and the last `lastError`. See *Delivery logs*. ### Subscribing **POST** `/webhooks` **GET** `/webhooks` **DELETE** `/webhooks/:id` ``` curl https://api.guveno.com/v1/webhooks \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "type": "api", "config": { "url": "https://example.com/guveno" }, "events": ["*"] }' ``` The response includes a `signingSecret`, shown only once, used to verify deliveries. To pin a secret you already manage, pass your own `signingSecret` (16–256 chars) in the create body; omit it and one is generated for you. **Scope to specific wallets**: pass `walletIds` (an array of wallet ids) to receive events only for those wallets. Omit it or send an empty array to receive events for **all** wallets in your company — offered only to a caller who can already see every wallet, since an all-wallets webhook also picks up the ones added later. Every webhook in the list response carries its `walletIds` so you can tell its scope. A webhook is only as visible as the wallets it watches. Listing, rotating its secret, reading its deliveries and deleting it all require being able to see every wallet it is scoped to — its delivery config and its event payloads describe those wallets, so they follow the same team boundary the events themselves do. ``` curl https://api.guveno.com/v1/webhooks \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "type": "api", "config": { "url": "https://example.com/guveno" }, "events": ["deposit.confirmed"], "walletIds": [12, 34] }' ``` ### Verifying signatures Every delivery includes an `x-guveno-signature: sha256=` header, an HMAC-SHA256 of the raw request body keyed with your signing secret. Verify it before trusting the payload: ``` import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody, header, signingSecret) { const expected = "sha256=" + createHmac("sha256", signingSecret) .update(rawBody) .digest("hex"); const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); } ``` ### Rotating the secret **POST** `/webhooks/:id/rotate-secret` Replace a webhook's signing secret, for scheduled rotation or after a suspected leak. Send a`signingSecret` to set your own, or an empty body to have one generated. The new secret is returned once in the response; deliveries are signed with it immediately, so update your receiver in the same change. ``` curl -X POST https://api.guveno.com/v1/webhooks/42/rotate-secret \ -H "Authorization: Bearer gv_live_your_key_here" \ -H "Content-Type: application/json" \ -d '{}' ``` ### Delivery logs **GET** `/webhooks/:id/deliveries` Inspect recent attempts, response codes and errors. Failed deliveries are retried automatically. Pass`limit` (1–100, default 50) to say how many to return; the newest come first. Each row carries the delivery's `status`, how many `attempts` it has taken, the `responseCode` your endpoint answered with, the `lastError` if it failed, and `nextAttemptAt` when another try is due. ``` curl https://api.guveno.com/v1/webhooks/42/deliveries?limit=20 \ -H "Authorization: Bearer gv_live_your_key_here" ``` ### Inspecting one delivery **GET** `/webhooks/:id/deliveries/:deliveryId` The whole record of a single attempt: `payload` is the exact JSON body we posted — the bytes the`x-guveno-signature` header is computed over, so it is what a signature mismatch is debugged against — and `responseBody` is what your endpoint replied, truncated to 2KB. Both are left out of the list above, which stays small enough to poll; read them one delivery at a time. `responseBody` is a diagnostic, never a signal: the delivery's outcome is decided by the status code alone. It is null when your endpoint returned no body, and `responseCode` is null when nothing reached it at all. Any secret belonging to the webhook itself is redacted out before it is stored. ``` curl https://api.guveno.com/v1/webhooks/42/deliveries/9001 \ -H "Authorization: Bearer gv_live_your_key_here" { "delivery": { "id": 9001, "webhookId": 42, "eventId": "7b0d9f2a-1c44-4e8b-9b31-2f6ad5c10e77", "eventType": "deposit.confirmed", "status": "delivered", "attempts": 1, "responseCode": 200, "lastError": null, "responseBody": "{\"ok\":true}", "payload": { "id": "7b0d9f2a-1c44-4e8b-9b31-2f6ad5c10e77", "type": "deposit.confirmed", "data": { } }, "nextAttemptAt": "2026-09-20T09:14:02.118Z", "createdAt": "2026-09-20T09:14:02.118Z", "updatedAt": "2026-09-20T09:14:02.400Z" } } ``` Source: https://guveno.com/docs#webhooks --- ## Errors & rate limits Errors use a consistent JSON shape and standard HTTP status codes. ### Error shape ``` { "statusCode": 403, "error": "Forbidden", "message": "Insufficient company permissions." } ``` ### Status codes - `400`: malformed request or invalid parameters. - `401`: missing, invalid, expired or revoked key. - `402`: no active plan yet. - `403`: the key's role lacks permission, the endpoint is dashboard-only, the chain/network isn't in your plan, or a hard plan cap is reached (free Developer tier only; on paid plans, wallets/addresses beyond your included limits are created normally and bill as monthly overage). - `404`: resource not found (or not in your company). - `409` / `422`: conflict or unprocessable request. - `429`: rate limited. - `5xx`: server error; retry with backoff. ### Rate limits A relaxed global ceiling of 300 requests per minute per IP applies to every endpoint. On top of that, value-moving writes have a tighter, per-caller limit, counted per API key and scoped to each endpoint: - `20`/min per caller: withdrawal prepare/broadcast and wallet create/update/delete. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). A `429` additionally sets `Retry-After` (seconds) and returns the standard error shape: ``` { "statusCode": 429, "error": "Too Many Requests", "message": "Rate limit exceeded. Try again in 30 seconds." } ``` On a `429`, wait for `Retry-After` before retrying; don't retry in tight loops. Rate limits guard burst traffic only; there is no monthly cap on API requests. Source: https://guveno.com/docs#errors --- ## Network status Check how Guveno's chain workers are tracking each network (the same data behind the dashboard status page) and nudge a wallet or address to re-scan. ### Chain sync status **GET** `/sync/status` Returns one row per chain/network the server tracks, with how far it has synced and its connection health. ``` curl https://api.guveno.com/v1/sync/status \ -H "Authorization: Bearer gv_live_your_key_here" ``` ``` { "items": [ { "chain": "ethereum", "network": "mainnet", "cursor": "20461123", // last processed block/ledger height "lastSyncedAt": "2026-06-14T11:59:50.000Z", "connectionStatus": "connected", "activeEndpoint": "https://...", // current upstream provider, may be null "lastError": null, "lastHealthyAt": "2026-06-14T11:59:50.000Z" } ] } ``` ### Status values - `connected`: the worker is tracking the chain normally. - `degraded`: syncing, but a provider is erroring or falling behind. - `disconnected`: no healthy provider; the chain isn't advancing. - `disabled`: this chain's worker isn't enabled on the server. A `connected` status with a recent `lastSyncedAt` means deposits and confirmations are flowing. If you see `degraded` or `disconnected`, events may be delayed until it recovers. ### Forcing a re-scan **POST** `/wallets/:id/sync` **POST** `/addresses/:id/sync` Hints the worker to re-scan a specific wallet or address now instead of waiting for the next poll. Useful right after you expect a deposit. Both return `{ "accepted": true }`. Source: https://guveno.com/docs#status --- ## Hot wallets A hot wallet is an automated, always-on signer: think of an exchange paying out withdrawals without a human in the loop. The SDK runs the whole prepare → sign → broadcast cycle in your process; the server never holds the key. ### When to use one Reach for a hot wallet when funds must move programmatically: payouts, sweeps, rebalancing. For funds you don't need to move automatically, keep the key offline and sign by hand instead (cold storage): the API flow is identical, only the signing step happens on an air-gapped machine. A common split is a small hot wallet for day-to-day flow and cold storage for the bulk of reserves. ### Setup A hot wallet needs two things: a Guveno client authenticated with an API key that holds `withdrawals:create` on the wallet, and a **key provider** that supplies the wallet's recovery phrase at signing time. ``` import { Guveno, FileKeyProvider } from "@guveno/wallet-sdk"; const guveno = new Guveno({ apiKey: process.env.GUVENO_API_KEY, // gv_live_... (baseUrl defaults to production) }); // Maps each wallet's key fingerprint -> recovery phrase, from a mounted secret. // For production, prefer a KMS/HSM-backed provider (see HSM & KMS). const keys = new FileKeyProvider("/run/secrets/guveno-keys.json"); // Load the wallet by id with the key provider, then withdraw from its addresses. const wallet = await guveno.loadWallet(10, { keys }); const withdrawal = await wallet.withdraw({ addressId: 50, assetId: 3, toAddress: "0xRecipient...", amount: "0.25", }); ``` Alternatively, source the secret straight from the server: load the wallet with your encryption password instead of a key provider: `guveno.loadWallet(10, encryptionPassword)`. Prefer the terminal? The CLI wraps the same flow: ``` guveno withdraw --wallet-id 10 --address-id 50 --asset-id 3 \ --to 0xRecipient... --amount 0.25 \ --keys ./keys.json # or omit --keys to sign with the server-held secret ``` ### How it signs For each withdrawal the hot wallet asks the server to `prepare` an unsigned transaction, unwraps the recovery phrase from the key provider, signs in-process, broadcasts the signed transaction, then drops the secret from memory. Sends from the same source address are serialized so concurrent withdrawals can't collide on a nonce. Automated signing covers **Ethereum, BNB Smart Chain, XRP, Bitcoin** (native SegWit / P2WPKH), **Polkadot and TRON**. ### Staying safe - The API key only authorizes `prepare`/`broadcast`; the actual spend still requires the key material your provider holds, so guard that host tightly. - Grant the key only `withdrawals:create` on the wallets it pays from, and give it an expiry. - Keep only what you need hot; hold the rest in cold storage. - In production, back the key provider with a KMS or HSM rather than a plaintext file. Source: https://guveno.com/docs#hot-wallets --- ## HSM & KMS Keep signing keys inside a hardware security module or cloud KMS instead of on disk. The SDK never depends on a specific provider; you plug in the unwrap call. ### The model Because Guveno is non-custodial, signing runs in your environment, which means you choose where the key lives. The SDK's key-provider abstraction sits in front of signing: instead of reading a phrase from a file, it can hand a **wrapped** (encrypted) blob to your KMS/HSM and use the result only for the moment a transaction is signed. This is the "unwrap-then-sign" model: the plaintext phrase exists transiently in process memory and is dropped immediately after. ### KMS key provider `KmsKeyProvider` stores only wrapped secrets (one per wallet key fingerprint) and calls a decrypt routine you supply, backed by AWS KMS, GCP KMS, HashiCorp Vault, or an HSM unwrap. The SDK ships no cloud dependency, so you keep full control of credentials and provider choice. ### Example ``` import { Guveno, KmsKeyProvider } from "@guveno/wallet-sdk"; const guveno = new Guveno({ apiKey: process.env.GUVENO_API_KEY }); const keys = new KmsKeyProvider({ // key fingerprint -> wrapped ciphertext (e.g. a KMS-encrypted recovery phrase) entries: { "ab12cd34ef": process.env.WRAPPED_KEY_AB12 }, // Your unwrap routine. Runs only at signing time; return the recovery phrase. decrypt: async (wrapped /*, keyFingerprint */) => { const out = await kms.decrypt({ CiphertextBlob: Buffer.from(wrapped, "base64") }); return out.Plaintext.toString("utf8"); }, }); const wallet = await guveno.loadWallet(10, { keys }); await wallet.withdraw({ addressId: 50, assetId: 3, toAddress: "0x...", amount: "0.25" }); ``` ### Good to know - The unwrap result must be the wallet's recovery phrase; the SDK checks its fingerprint matches the wallet key before signing, so a misconfigured mapping fails fast instead of signing wrong. - This protects the key *at rest* and limits exposure to the signing moment. It is not in-HSM signing, since the phrase is briefly in process memory to build the signature. - Rotate by re-wrapping the phrase under a new KMS/HSM key and updating the `entries` map; the fingerprint stays the same. Source: https://guveno.com/docs#hsm-kms --- ## Node.js SDK The official @guveno/wallet-sdk wraps the REST API and handles wallet key derivation, signing and encryption. It authenticates with an API key. Working in Python? The Python SDK offers the same features. [JS@guveno/wallet-sdkon npm→](https://www.npmjs.com/package/@guveno/wallet-sdk) ### Install ``` npm install @guveno/wallet-sdk ``` ### Using an API key Construct the client with your API key. The base URL defaults to the production API, so you only set it for a self-hosted server. ``` import { Guveno } from "@guveno/wallet-sdk"; const guveno = new Guveno({ apiKey: process.env.GUVENO_API_KEY, // gv_live_... }); const wallets = await guveno.listWallets(); ``` ### Creating & deriving wallets The server is the source of truth: a recovery phrase is generated locally, sealed to your encryption key, and stored on the server; the SDK re-fetches and unseals it on demand. Pass your encryption password (the one you set during dashboard onboarding) when you create or load a wallet; that's what unseals the phrase locally. ``` import { Guveno } from "@guveno/wallet-sdk"; const guveno = new Guveno({ apiKey: process.env.GUVENO_API_KEY }); // Generates a phrase, seals it to your encryption key, and stores it on the server. const created = await guveno.createWallet({ name: "treasury-eth", chain: "ethereum", network: "mainnet", encryptionPassword: process.env.GUVENO_ENCRYPTION_PASSWORD, }); console.log(created.wallet.id, created.firstAddress.address); console.log("Back up:", created.mnemonic); // The returned wallet is already loaded — derive the next address (server index). const next = await created.wallet.deriveAddress({ label: "deposits" }); created.wallet.lock(); ``` If key file protection is enabled, pass the original bytes when creating, importing, or loading: ``` import { readFile } from "node:fs/promises"; const keyFile = await readFile("/secure/original-photo.png"); const wallet = await guveno.loadWallet(10, { encryptionPassword: process.env.GUVENO_ENCRYPTION_PASSWORD, keyFile, }); keyFile.fill(0); // Use wallet, then wallet.lock() when finished. ``` ### Vaults: one phrase, many chains `createWallet` and `importWallet` create a vault for the phrase, or join the vault that already holds it when you have key access. `addWalletToVault` adds another chain wallet to an existing vault without generating anything new: it unseals the vault's phrase from one of its wallets and derives from it. Pass `derivationPathPrefix` to put a second wallet for the same chain in the same vault. ``` const [treasury] = await guveno.listVaults(); // metadata only, no password const { wallet: eth } = await guveno.addWalletToVault({ vaultId: treasury.id, name: "treasury-eth", chain: "ethereum", network: "mainnet", encryptionPassword: process.env.GUVENO_ENCRYPTION_PASSWORD, }); // A second Ethereum wallet in the same vault needs its own derivation path. const { wallet: ops } = await guveno.addWalletToVault({ vaultId: treasury.id, name: "ops-eth", chain: "ethereum", network: "mainnet", derivationPathPrefix: "m/44'/60'/1'/0", encryptionPassword: process.env.GUVENO_ENCRYPTION_PASSWORD, }); ``` ### Reading balances Balances need only the API key, no encryption password. A loaded wallet exposes getters, or call `guveno.client` directly with a wallet id. Amounts are decimal strings in the asset's main unit and carry the full asset metadata. ``` const wallet = await guveno.loadWallet(walletId, password); const byAddress = await wallet.getBalances(); // per-address, per-asset const totals = await wallet.getTotals(); // aggregated per asset const stats = await wallet.getStats(); // holdings, flows, top addresses // Or via the low-level client, by wallet id: await guveno.client.getWalletBalanceTotals(walletId); // Company-wide totals per asset (optionally scoped to a chain/network): await guveno.client.getCompanyBalanceSummary({ chain: "ethereum", network: "mainnet" }); ``` ### Signing locally Withdrawals follow prepare → sign → broadcast with signing in your process; see **Hot wallets**. The mnemonic is unsealed locally (or supplied by a key provider) and never leaves your environment. See the SDK README for the full method list and worked examples. Source: https://guveno.com/docs#sdk --- ## Python SDK The official guveno package for Python mirrors the Node.js SDK feature-for-feature: the REST API, wallet key derivation, signing and encryption — with an API key for auth. [guvenoon PyPI→](https://pypi.org/project/guveno/) ### Install ``` pip install guveno ``` Requires Python 3.10+. > The Python and Node.js SDKs are wire- and key-compatible: the same recovery phrase derives the same addresses, and secrets sealed by one SDK open in the other — mix them freely across services. ### Using an API key Construct the client with your API key. The base URL defaults to the production API, so you only set it for a self-hosted server. ``` import os from guveno import Guveno guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"]) # gv_live_... wallets = guveno.list_wallets() ``` ### Creating & deriving wallets The server is the source of truth: a recovery phrase is generated locally, sealed to your encryption key, and stored on the server; the SDK re-fetches and unseals it on demand. Pass your encryption password (the one you set during dashboard onboarding) when you create or load a wallet; that's what unseals the phrase locally. ``` import os from guveno import Guveno guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"]) # Generates a phrase, seals it to your encryption key, and stores it on the server. created = guveno.create_wallet( name="treasury-eth", chain="ethereum", network="mainnet", encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"], ) print(created.wallet.id, created.first_address["address"]) print("Back up:", created.mnemonic) # The returned wallet is already loaded — derive the next address (server index). next_addr = created.wallet.derive_address(label="deposits") created.wallet.lock() ``` If key file protection is enabled, pass the original bytes when creating, importing, or loading: ``` from pathlib import Path key_file = Path("/secure/original-photo.png").read_bytes() wallet = guveno.load_wallet(10, encryption_password, key_file=key_file) del key_file # Use wallet, then wallet.lock() when finished. ``` ### Vaults: one phrase, many chains `create_wallet` and `import_wallet` create a vault for the phrase, or join the vault that already holds it when you have key access. `add_wallet_to_vault` adds another chain wallet to an existing vault without generating anything new. Pass `derivation_path_prefix` to put a second wallet for the same chain in the same vault. ``` treasury = guveno.list_vaults()[0] # metadata only, no password eth = guveno.add_wallet_to_vault( treasury.id, "treasury-eth", "ethereum", "mainnet", encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"], ) # A second Ethereum wallet in the same vault needs its own derivation path. ops = guveno.add_wallet_to_vault( treasury.id, "ops-eth", "ethereum", "mainnet", derivation_path_prefix="m/44'/60'/1'/0", encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"], ) ``` ### Reading balances Balances need only the API key, no encryption password. A loaded wallet exposes getters, or call `guveno.client` directly with a wallet id. Amounts are decimal strings in the asset's main unit and carry the full asset metadata. ``` wallet = guveno.load_wallet(wallet_id, password) by_address = wallet.get_balances() # per-address, per-asset totals = wallet.get_totals() # aggregated per asset stats = wallet.get_stats() # holdings, flows, top addresses # Or via the low-level client, by wallet id: guveno.client.get_wallet_balance_totals(wallet_id) # Company-wide totals per asset (optionally scoped to a chain/network): guveno.client.get_company_balance_summary(chain="ethereum", network="mainnet") ``` ### Signing locally Withdrawals follow prepare → sign → broadcast with signing in your process; see **Hot wallets**. The mnemonic is unsealed locally (or supplied by a `FileKeyProvider` / `KmsKeyProvider`) and never leaves your environment — including fully offline Polkadot extrinsic building. See the package README on [PyPI](https://pypi.org/project/guveno/) for the full method list and worked examples. Source: https://guveno.com/docs#python-sdk --- ## Command-line (CLI) The @guveno/cli wraps the SDK to manage your server-side wallets from the terminal. Handy for scripts, CI and quick checks. It authenticates with an API key. [@guveno/clion npm→](https://www.npmjs.com/package/@guveno/cli) ### Install The CLI builds on the SDK and exposes a `guveno` command: ``` npm install -g @guveno/cli guveno --help ``` ### Authentication Run `guveno init` once to save your API key locally, then every command uses it. You can also pass `--api-key gv_live_...` inline or read it from an environment variable (`$GUVENO_API_KEY`). The base URL defaults to the production API; advanced users can override it (see Advanced configuration). ``` # Save your key once (prompts if you omit --api-key) guveno init --api-key gv_live_your_key_here # ...or per-command, from an environment variable export GUVENO_API_KEY=gv_live_your_key_here guveno list-wallets --json ``` Commands that create, derive, reveal, or sign need your **encryption password** to unlock your key. Provide it with `--encryption-password`, `--encryption-password-env`, the `$GUVENO_ENCRYPTION_PASSWORD` variable, or the interactive prompt. Only the API key and an optional base URL are stored on disk (under `~/.guveno`); your encryption password and recovery phrases are not. If key file protection is enabled, also pass `--key-file /secure/original-photo.png`. The exact file contents are read locally and never uploaded or saved to CLI configuration. ### Commands - **Setup**: `init`, `whoami`, `logout`. - **Vaults**: `list-vaults`, `get-vault`. Wallet creation takes `--vault ` to add a chain wallet to an existing vault, `--vault-name` and `--team` for a new one, and `--derivation-path` for a custom prefix. - **Wallets**: `create-wallet`, `import-wallet`, `list-wallets` (`--vault` filter), `get-wallet`, `derive-address`, `list-addresses`, `rename-wallet`, `delete-wallet`, `reveal-mnemonic`. - **Balances**: `balances`, `totals`, `stats`, `company-balances`. - **Webhooks**: `create-webhook`, `list-webhooks`, `webhook-deliveries`, `webhook-delivery`, `delete-webhook`. - **Withdrawals**: `withdraw`. Every command also accepts `--json` for machine-readable output. ### Examples ``` # One-time setup guveno init --api-key gv_live_your_key_here # Create a wallet (prints the recovery phrase once for backup) and derive an address export GUVENO_ENCRYPTION_PASSWORD=... # unlocks your key guveno create-wallet treasury-btc --chain bitcoin --words 24 --vault-name Treasury guveno derive-address 12 --label fees --json # Add an Ethereum wallet to the same vault (same phrase, no new backup) guveno list-vaults guveno create-wallet treasury-eth --chain ethereum --vault 3fa85f64-5717-4562-b3fc-2c963f66afa6 # List wallets and subscribe to events guveno list-wallets --json guveno create-webhook --type api --url https://example.com/hook --all-events ``` Wallets, addresses and the (sealed) recovery phrase live on the server; the local directory is just a cache for your API key. All signing and decryption run locally; secrets never leave your machine. Source: https://guveno.com/docs#cli --- ## Postman collection Import the Guveno API into Postman to explore every endpoint interactively, with auth and variables already wired up. ### Download Grab the collection and import it into Postman (*Import → File*, or paste the URL): ``` /guveno-api.postman_collection.json ``` [Download guveno-api.postman_collection.json](https://guveno.com/guveno-api.postman_collection.json) ### Setup The collection ships with two variables. Set them once and every request is ready: - `apiKey`: a key from the dashboard (Developer → API Keys), e.g. `gv_live_...`. Used as the Bearer token for the whole collection. - `baseUrl`: defaults to `https://api.guveno.com/v1`; point it at your own server if self-hosting. Convenience variables (`vaultId`, `walletId`, `assetId`, `addressId`, `withdrawalId`, `webhookId`) fill in path parameters so requests run without editing URLs. ### What’s inside Folders for Identity, Vaults, Wallets, Assets, Balances, Transactions, Withdrawals, Webhooks and Network status: the wallet operations an API key performs. Collection-level Bearer auth applies your `apiKey` automatically, and the withdrawal requests pre-set an `Idempotency-Key` header. It mirrors this reference, so anything documented here is a click away in Postman. Source: https://guveno.com/docs#postman --- ## Advanced configuration Sensible defaults mean most integrations need no configuration. These knobs are here for self-hosting and local development. ### Base URL By default everything talks to the production API at `https://api.guveno.com/v1`, so you never need to set it. If you run a self-hosted Guveno server (or point at a staging environment), override the base URL. Keep the `/v1` path. - **SDK**: pass `baseUrl` to the client: `new GuvenoApiClient({ apiKey, baseUrl })`. - **CLI**: pass `--base-url` on any command, or save it once with `guveno init --base-url ...`. - **Either**: set the `GUVENO_API_BASE_URL` environment variable. ### Environment variables - `GUVENO_API_KEY`: the `gv_live_...` key used by the SDK and CLI when no key is passed explicitly. - `GUVENO_API_BASE_URL`: override the API base URL (defaults to production). - `GUVENO_ENCRYPTION_PASSWORD`: unlocks your encryption key for create/derive/reveal/sign without an interactive prompt. - `GUVENO_WALLET_HOME`: directory for the CLI's local config cache (defaults to `~/.guveno`). Source: https://guveno.com/docs#advanced