Skip to main content

Guveno Wallet SDK for Python

Add secure crypto custody to your platform — create and manage multi-chain wallets, automate withdrawals, and receive real-time webhooks for Bitcoin, Ethereum, XRP, Polkadot, and more. Built for exchanges, fintechs, and platforms.

A typed Python SDK with client-side key encryption and a fully offline signing path, so the server never holds plaintext key material. Fully wire- and key-compatible with the Node.js SDK: the same account derives the same addresses, and secrets sealed by one SDK open in the other.

Prefer the terminal? The @guveno/cli package wraps the same API with the same features.

Install

pip install guveno

Requires Python 3.10+.

How it works

There are two credentials, and they do different jobs:

  • An API key (gv_live_...) authenticates every request. Generate one in the Guveno dashboard. It carries your company and an explicit set of allowed actions, granted per wallet (plus a couple of global actions). A request for a wallet or action the key wasn't granted is rejected.
  • Your encryption password decrypts your recovery phrases. The server only ever stores them sealed to your account's encryption key; the password (set during dashboard onboarding) unlocks that key locally and never leaves your process.

Wallets, addresses, and the sealed secret all live on the server. Listing and reading metadata needs only the API key. Signing (withdrawing, deriving addresses, revealing a phrase) additionally needs your encryption password, because that's what decrypts the key.

Everything is organised as Vault → Wallet → Address:

  • A vault is one recovery phrase: the custody boundary. The members who hold a sealed copy of the phrase (key access) are set on the vault, and that is what decides who can sign.
  • A wallet is one chain and network inside a vault, at one account-level derivation path (derivation_path_prefix, for example m/44'/60'/0'/0). Every wallet in a vault derives from the same phrase and inherits the vault's team. A vault holds one wallet per chain, network, and path; a second Ethereum wallet in the same vault needs a different path.
  • An address is one receiving address of a wallet, at <prefix>/<account_index> (<prefix><account_index> on Polkadot).

Creating a wallet from a new phrase creates its vault. Adding more chains to that vault reuses the phrase, so one backup covers all of them.

Quick start

import os
from guveno import Guveno

guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"])

# Browse — metadata only, no password needed.
wallets = guveno.list_wallets()
# Filter with chain/network/vault_id, or resolve ids you already hold
# (up to 100 per call) instead of paging the whole list.
watched = guveno.list_wallets(ids=[12, 7, 3])

# Load a wallet to sign with it: fetches the sealed secret and unlocks it locally.
wallet = guveno.load_wallet(wallets[0].id, os.environ["GUVENO_ENCRYPTION_PASSWORD"])

# Derive and register the next receive address (uses the server-tracked index).
result = wallet.derive_address(label="deposits")
print("Deposit to", result.address["address"])

withdrawal = wallet.withdraw(
    # Omit address_id to auto-select the source: the server picks an address with
    # enough balance — and for Bitcoin aggregates UTXOs across the wallet.
    asset_id=1,
    to_address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    amount="1.5",
)
print(withdrawal.status)  # 'broadcast' — confirmation arrives via webhooks

wallet.lock()  # wipe key material from memory when done

load_wallet() accepts a numeric id, a wallet name, or a {"name", "chain", "network"} selector:

guveno.load_wallet(wallets[0].id, password)   # by id
guveno.load_wallet("treasury", password)      # by name
guveno.load_wallet({"name": "treasury", "chain": "bitcoin", "network": "mainnet"}, password)

Names are only unique within a (chain, network), so a bare name that matches wallets on more than one chain/network raises — pass the scoped selector or the id to disambiguate.

withdraw() runs the full prepare → sign → broadcast loop: the server returns an unsigned payload, the SDK signs it in process, and broadcasts the signed transaction. The recovery phrase never leaves your machine.

By default the source is auto-selected. Pass address_id to send from one specific address, or (Bitcoin only) source_address_ids to restrict UTXO aggregation to a chosen subset of the wallet's addresses. Account-based chains can't combine balances across addresses, so an auto withdrawal raises if no single address covers the amount.

Fees use the server's suggestion by default. Override per call with ethereum_gas (an EthereumGasOverrides) or bitcoin_fee_rate (sat/vB). Bitcoin inputs are fixed at prepare time, so a fee rate much higher than suggested can fail to fit the selected inputs.

Creating and importing wallets

# Generate a new recovery phrase, derive the first address, seal it, and register it.
created = guveno.create_wallet(
    name="treasury-btc",
    chain="bitcoin",
    network="mainnet",  # mainnet | testnet (Bitcoin) | sepolia (Ethereum) | paseo (Polkadot)
    words=24,           # 12 or 24-word recovery phrase (defaults to 12)
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)
print(created.first_address["address"])   # bc1...
print("Back this up:", created.mnemonic)  # shown once — store it safely

# Import an existing phrase as a new server-side wallet.
imported = guveno.import_wallet(
    name="restored-xrp",
    chain="xrp",
    network="mainnet",
    mnemonic="test test test test test test test test test test test junk",
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)

Both return a Wallet that's already loaded and ready to use. A new phrase creates a new vault, named after the wallet unless you pass vault_name; team_id scopes the vault to a team (omit it to inherit your own team). Importing a phrase that already backs one of your vaults adds the wallet to that vault instead, provided you hold key access to it.

Vaults: one phrase, many chains

# Browse vaults (metadata only).
for vault in guveno.list_vaults():
    print(vault.name, vault.fingerprint[:8], vault.wallet_count, vault.access)

# Add another chain to an existing vault. The phrase is fetched sealed from the
# server and unlocked locally; nothing about the key is sent.
eth = guveno.add_wallet_to_vault(
    created.wallet.detail.vault_id,
    name="treasury-eth",
    chain="ethereum",
    network="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(
    created.wallet.detail.vault_id,
    name="ops-eth",
    chain="ethereum",
    network="mainnet",
    derivation_path_prefix="m/44'/60'/1'/0",
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)

Every wallet record carries vault_id, vault_name, team_id, and derivation_path_prefix. list_wallets(vault_id=...) lists one vault's wallets, and list_wallets(ids=[...]) resolves a set of ids you already hold (at most 100 per call). A custom prefix must match the chain's grammar (BIP32 with ' for hardened segments; //name// junctions on Polkadot), and the server rejects addresses that are not derived at exactly the wallet's prefix, so keep using derive_address() rather than registering addresses by hand.

Using a loaded wallet

wallet = guveno.load_wallet(wallet_id, encryption_password)

wallet.withdraw(asset_id=..., to_address=..., amount=...)                  # auto-select source
wallet.withdraw(address_id=..., asset_id=..., to_address=..., amount=...) # specific source
next_addr = wallet.derive_address(label="deposits")  # next address at the server index
page = wallet.list_addresses()
balances = wallet.get_balances()      # per-address, per-asset
totals = wallet.get_totals()          # aggregated per asset
stats = wallet.get_stats()            # holdings, flows, top addresses
phrase = wallet.reveal_mnemonic()     # audited server-side
status = wallet.get_withdrawal(withdrawal.id)

wallet.id; wallet.name; wallet.chain; wallet.network  # metadata properties
  • Per-source serialization — withdrawals from the same source (a specific address, or a wallet when auto-selecting) are queued so concurrent sends never collide on a nonce or reuse a UTXO; different sources run in parallel.
  • Idempotency — an idempotency_key is auto-generated per withdrawal; pass your own to make a retried withdraw(...) replay-safe. Without one, a retry prepares a new withdrawal.
  • All custodied chains are signable; Polkadot extrinsics are built fully offline from metadata the server includes in the prepared payload.

When a send's outcome is unknown

A broadcast is the one call here whose failure does not mean "nothing happened". If the node accepted the transaction and the reply was lost, the payment is out there. The SDK raises a distinct error for that case so you never mistake it for a refusal:

from guveno import SendOutcomeUnknownError

try:
    wallet.withdraw(address_id=..., asset_id=..., to_address=..., amount=...)
except SendOutcomeUnknownError as error:
    # The transaction MAY be live. Do not sign a replacement.
    alert_ops(error.withdrawal_id, error.tx_hash)

What to do with one:

  • Look up error.tx_hash on the chain. If it is there, the withdrawal settles by itself and wallet.get_withdrawal(id) will show it reach confirmed.
  • Or call guveno.client.broadcast_withdrawal(...) again with the identical signed_raw_tx. The same bytes are the same transaction, so resending them is not a second payment — and the server accepts only those bytes for this withdrawal.

What not to do: prepare again, sign again, or retry withdraw(). On an account chain a replacement is signed at the next nonce, so if the first transaction did land you have paid twice. The server enforces this too — until the send is resolved, preparing any new withdrawal from that address fails with a conflict, and the broadcast endpoint refuses any signed transaction other than the one it already holds.

Headless signing (KMS / HSM / file)

For automated signers that hold their own key material, load a wallet with a key provider instead of a password — no encryption password, and the sealed secret is never fetched from the server:

from guveno import Guveno, FileKeyProvider, KmsKeyProvider

guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"])

# keys.json: { "<keyFingerprint>": "<bip39 mnemonic>", ... }
wallet = guveno.load_wallet(wallet_id, FileKeyProvider("/run/secrets/keys.json"))
wallet.withdraw(address_id=100, asset_id=1, to_address="0x70997...", amount="1.5")

Don't want a plaintext key file? Back it with AWS KMS, GCP KMS, Vault, or an HSM — the plaintext mnemonic exists only transiently in memory while a withdrawal is signed:

def unwrap(ciphertext: str, key_fingerprint: str) -> str:
    out = kms.decrypt(CiphertextBlob=base64.b64decode(ciphertext))
    return out["Plaintext"].decode("utf-8")  # the mnemonic

keys = KmsKeyProvider(entries={"<keyFingerprint>": "<base64 KMS ciphertext>"}, decrypt=unwrap)
wallet = guveno.load_wallet(wallet_id, keys)

Key-provider wallets can withdraw() and list_addresses(); derive_address() and reveal_mnemonic() need the encryption-password path (they seal/unseal against the server).

Balances

Reading balances needs only the API key — no encryption password or key provider. The quickest path is the getters on a loaded wallet, but guveno.client exposes the same endpoints if you only have a wallet id:

# Per-address, per-asset balances — one entry per address.
for addr in wallet.get_balances():
    for b in addr.balances:
        print(addr.address, b.asset.symbol, b.total)

# Aggregated per-asset totals across the wallet's addresses.
for t in wallet.get_totals():
    print(t.asset.symbol, t.total)

# Holdings, lifetime in/out flows, and the top-holding addresses.
stats = wallet.get_stats()
print(stats.address_count, stats.transaction_count)

total/amount are decimal strings in the asset's main unit (e.g. "1.5" ETH), and each carries the full asset (symbol, decimals, contract address, …) so you don't need a separate asset lookup.

Without a loaded wallet, call the client directly — and roll up the whole company with get_company_balance_summary:

totals = guveno.client.get_wallet_balance_totals(wallet_id)

# Company-wide totals per asset, optionally scoped to a chain/network.
company = guveno.client.get_company_balance_summary()
eth_only = guveno.client.get_company_balance_summary(chain="ethereum", network="mainnet")

Transactions and review marks

Transactions come off the client, newest first, with the usual filters and cursor pagination:

deposits = guveno.client.list_transactions(wallet_id=wallet_id, direction="incoming", limit=50)
page = guveno.client.list_transaction_page(chain="bitcoin", status="confirmed")
one = guveno.client.get_transaction(9001)

Each 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:

  • savedpersonal. Your own "review later" queue. Another member's saved state is never reported to you, and yours is never reported to them.
  • flagged, note and the assigneeshared, 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 / note at the top level are yours (an API key acts as its creator); shared lists every member's flag and note with its author:

tx = guveno.client.get_transaction(9001)
print(tx.marks.saved, tx.marks.open_flags)
for mark in tx.marks.shared:
    print(mark.user.email, "flagged" if mark.flagged else "noted", mark.note)

# Only the arguments you pass change — flagging leaves the ★ and the note alone.
guveno.client.set_transaction_marks(9001, flagged=True, note="unexpected fee")
# `note=None` means "leave it alone", so clearing is explicit:
guveno.client.set_transaction_marks(9001, clear_note=True)

A flag's lifecycle

A flag is raised, then either resolved (reviewed and closed) or withdrawn (flagged=False, as if it had never been raised) — which is what makes the flagged list a queue rather than a pile. It may also name one member it is waiting on, who is notified once, in the dashboard and by email.

# Hand it to someone: they must already be able to see the transaction.
guveno.client.set_transaction_marks(9001, flagged=True, assign_to_user_id=8)
guveno.client.set_transaction_marks(9001, unassign=True)   # clear the assignee

# Closing is a separate call, because the reviewer is usually not the raiser.
guveno.client.resolve_transaction_flag(9001, raised_by_user_id=3)
guveno.client.resolve_transaction_flag(9001, raised_by_user_id=3, resolved=False)  # re-open

# The review lists, and their counts.
waiting = guveno.client.list_transactions(marked="flagged_open")
mine = guveno.client.list_transactions(marked="assigned")
summary = guveno.client.get_transaction_mark_summary()
print(summary.saved, summary.open_flags, summary.waiting_on_me)

Marks are organizational only — they never affect crediting, confirmations or balances. Reading and writing them, resolving another member's flag included, needs only the transactions:read action.

Webhooks and low-level access

The high-level facade covers wallet and vault operations. For everything else (webhooks, renaming or deleting wallets, listing withdrawals, vault administration) use the underlying client at guveno.client (a GuvenoApiClient):

webhook = guveno.client.create_webhook(
    type="api",
    config={"url": "https://example.com/webhooks/guveno"},
    events=["deposit.confirmed", "withdrawal.confirmed"],  # or ["*"] for all
)
print(webhook.signing_secret)  # shown only here — store it to verify x-guveno-signature

deliveries = guveno.client.list_webhook_deliveries(webhook.id, limit=50)

# One delivery in full: `payload` is the exact body posted (the bytes the
# signature covers) and `response_body` is what your endpoint replied.
delivery = guveno.client.get_webhook_delivery(webhook.id, deliveries[0].id)

All event types are exported as WEBHOOK_EVENT_TYPES. Managing webhooks requires an API key with the webhooks:manage action.

Addresses without the seed

wallet.derive_address() derives locally, so it needs the encryption password. A wallet that has registered its account xpub (wallet.enable_server_derivation()) can have the server derive instead, from an API key alone:

address = guveno.client.generate_address(wallet_id, label="customer-4471")

# Pin the index to give one customer the same address on two wallets that share
# a vault and a derivation prefix, and so an account xpub.
matched = guveno.client.generate_address(
    bsc_wallet_id,
    label="customer-4471",
    account_index=address.account_index,
)

Omit account_index and the server allocates the wallet's next one, so two concurrent calls get two consecutive addresses. Pass one and an index already registered on that wallet is refused with a 409, archived addresses included: nothing is reassigned and no free index is substituted, because the address on a taken index may already belong to someone else. Allocation stays "highest + 1", so pinning a high index moves the wallet's next automatic index above it. Valid indexes run from 0 to MAX_ACCOUNT_INDEX. Needs the wallets:generate_address action; Polkadot cannot derive from a public key and is refused.

Vault administration lives on the client too: create_vault() registers a phrase without a wallet yet (needs wallets:create), while update_vault() (rename) and delete_vault() (archive; refused while it has active wallets or funded addresses) are accepted from dashboard user sessions only, because each one changes who can see every wallet in the vault. The server rejects API keys on those routes.

Who holds a copy of a vault's phrase has no SDK surface at all. Passing key material to another member decrypts the sharer's own copy in their browser, re-seals it to the recipient, and waits for the recipient to accept — a dashboard ceremony, and the only way a copy is ever made. Do it under Vault → Key holders.

Supported chains

Chain Address style
Bitcoin native SegWit bc1...
Ethereum 0x...
BNB Smart Chain 0x... (same address as Ethereum)
XRP classic r...
Polkadot SS58 (1... mainnet)
TRON Base58Check T...

We're adding new chains regularly — these six are live today. All use standard HD derivation, so a recovery phrase restores the same accounts in any compatible wallet (Polkadot uses sr25519 substrate junctions, matching Polkadot-JS / Talisman; BSC shares Ethereum's derivation, matching MetaMask/Trust Wallet).

Optional key file

Enable Also require a key file during dashboard encryption setup or under Profile → Wallet encryption. Setup requires a fresh test unlock before saving. The file can be any format, including an image (non-empty, up to 20 MiB). Use its exact original bytes: copying or renaming is fine; edits, image compression, and metadata changes prevent unlocking. Keep a separate backup. Public/shared files provide little additional protection. A generated random file is also available.

Once enabled, both your encryption password and the file are required to unlock and recover wallet secrets through Guveno. Neither input is uploaded or stored by the SDK. Guveno cannot reset or recover them. An independently backed-up wallet recovery phrase can still recover that wallet. This setting protects your access across all wallets; other authorized members retain their own access. Old encrypted backups retain their old credentials, and keys already unlocked in another process remain usable until locked. Changing protection keeps wallet addresses and recovery phrases unchanged.

from pathlib import Path
key_file = Path('/secure/original-photo.png').read_bytes()
wallet = guveno.load_wallet(wallet_id, encryption_password, key_file=key_file)
# Use wallet, then wallet.lock() when finished.
del key_file  # Python cannot guarantee wiping immutable bytes from memory.

create_wallet() and import_wallet() also accept key_file: bytes alongside encryption_password. EncryptionSession.unlock() / unlock_from_user() and decrypt_user_private_key() accept key_file as their third argument. Omit it for password-only accounts; an unexpected file or combination with a key provider fails.

Release files for guveno 1.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for guveno 1.7.0
File Size Uploaded
guveno-1.7.0.tar.gz 61.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for guveno 1.7.0
File Interpreter ABI Platform
guveno-1.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 140.1 kB

Release files / guveno-1.7.0.tar.gz

Download URL guveno-1.7.0.tar.gz
Size 61.9 kB
Tags Source
SHA-256 checksum
How to use checksums
341b5cdfd942c956fed77243ff5ee3c3f7613f44f0c690d04b5b9a3ee2cc175a
BLAKE2b-256 checksum
How to use checksums
a2882eb6a4a78cc83d4fc735279085b88445b383c2f0e93176b09e279cad4072
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.2

Release files / guveno-1.7.0-py3-none-any.whl

Download URL guveno-1.7.0-py3-none-any.whl
Size 78.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0e7462f38085d946d6d2652d3c07d1edc2484ffa727b649167890eb752ba6ce3
BLAKE2b-256 checksum
How to use checksums
39b95559a7519794ab5fd61f223d8031c88bdd358d996f752de11caac7aec522
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.2

Release history Release notifications | RSS feed

This release

1.7.0 This release

2 release files

1.6.3

2 release files

1.6.2

2 release files

1.6.1

2 release files

1.6.0

2 release files

1.4.0

2 release files

1.3.1

2 release files

1.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page