Keyboard shortcuts

Press โ† or โ†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Using Turnkey

Turnkey keeps the wallet's keys inside a secure enclave. The SDK ships Turnkey-backed signers, so a server can run wallets without holding any signing key material, apart from the keys exported from Turnkey for some features: signing happens inside Turnkey, and what the server holds is an API credential whose permissions you control with Turnkey policies.

Turnkey is meant for server-side deployments. Depending on the policy you attach to the server's credential, it supports two ways of sending payments: the server signs everything itself, or each payment is approved by the end user via Client signing.

The SDK connects to an existing Spark wallet in your Turnkey organization or sub-organization. Creating the wallet itself is done with Turnkey directly and is out of the SDK's scope (see Turnkey's Spark support for creating the wallet account).

Connecting

Create the signers with create_turnkey_signercreate_turnkey_signercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignerCreateTurnkeySignerCreateTurnkeySigner and connect with connect_with_signerconnect_with_signerconnectWithSignerconnectWithSignerconnectWithSignerconnectWithSignerconnectWithSignerConnectWithSignerConnectWithSigner, the same way as any external signer:

Rust
let turnkey_config = TurnkeyConfig {
    base_url: None,
    organization_id: "<turnkey sub-organization id>".to_string(),
    api_public_key: "<api public key hex>".to_string(),
    api_private_key: "<api private key hex>".to_string(),
    wallet_id: "<turnkey wallet id>".to_string(),
    network: Network::Mainnet,
    account_number: None,
    // Set after the first connect to make later signer setup network-free
    identity_public_key: None,
    retry: None,
    max_rps: None,
    proxy: None,
};

let signers = create_turnkey_signer(turnkey_config).await?;

let mut config = default_config(Network::Mainnet);
config.api_key = Some("<breez api key>".to_string());

let sdk = connect_with_signer(ConnectWithSignerRequest {
    config,
    breez_signer: signers.breez_signer,
    spark_signer: signers.spark_signer,
    storage_dir: "./.data".to_string(),
})
.await?;
Swift
let turnkeyConfig = TurnkeyConfig(
    baseUrl: nil,
    organizationId: "<turnkey sub-organization id>",
    apiPublicKey: "<api public key hex>",
    apiPrivateKey: "<api private key hex>",
    walletId: "<turnkey wallet id>",
    network: Network.mainnet,
    accountNumber: nil,
    // Set after the first connect to make later signer setup network-free
    identityPublicKey: nil,
    retry: nil,
    maxRps: nil
)

let signers = try await createTurnkeySigner(config: turnkeyConfig)

var config = defaultConfig(network: Network.mainnet)
config.apiKey = "<breez api key>"

let sdk = try await BreezSdkSpark.connectWithSigner(request: ConnectWithSignerRequest(
    config: config,
    breezSigner: signers.breezSigner,
    sparkSigner: signers.sparkSigner,
    storageDir: "./.data"
))
Kotlin
val turnkeyConfig = TurnkeyConfig(
    baseUrl = null,
    organizationId = "<turnkey sub-organization id>",
    apiPublicKey = "<api public key hex>",
    apiPrivateKey = "<api private key hex>",
    walletId = "<turnkey wallet id>",
    network = Network.MAINNET,
    accountNumber = null,
    // Set after the first connect to make later signer setup network-free
    identityPublicKey = null,
    retry = null,
    maxRps = null
)

try {
    val signers = createTurnkeySigner(turnkeyConfig)

    val config = defaultConfig(Network.MAINNET)
    config.apiKey = "<breez api key>"

    val sdk = connectWithSigner(ConnectWithSignerRequest(
        config = config,
        breezSigner = signers.breezSigner,
        sparkSigner = signers.sparkSigner,
        storageDir = "./.data"
    ))
} catch (e: Exception) {
    // handle error
}
C#
var turnkeyConfig = new TurnkeyConfig(
    baseUrl: null,
    organizationId: "<turnkey sub-organization id>",
    apiPublicKey: "<api public key hex>",
    apiPrivateKey: "<api private key hex>",
    walletId: "<turnkey wallet id>",
    network: Network.Mainnet,
    accountNumber: null,
    // Set after the first connect to make later signer setup network-free
    identityPublicKey: null,
    retry: null,
    maxRps: null
);

var signers = await BreezSdkSparkMethods.CreateTurnkeySigner(turnkeyConfig);

var config = BreezSdkSparkMethods.DefaultConfig(Network.Mainnet) with
{
    apiKey = "<breez api key>"
};

var sdk = await BreezSdkSparkMethods.ConnectWithSigner(new ConnectWithSignerRequest(
    config: config,
    breezSigner: signers.breezSigner,
    sparkSigner: signers.sparkSigner,
    storageDir: "./.data"
));
Javascript
const turnkeyConfig: TurnkeyConfig = {
  baseUrl: undefined,
  organizationId: '<turnkey sub-organization id>',
  apiPublicKey: '<api public key hex>',
  apiPrivateKey: '<api private key hex>',
  walletId: '<turnkey wallet id>',
  network: 'mainnet',
  accountNumber: undefined,
  // Set after the first connect to make later signer setup network-free
  identityPublicKey: undefined,
  retry: undefined,
  maxRps: undefined
}

const signers = await createTurnkeySigner(turnkeyConfig)

const config = defaultConfig('mainnet')
config.apiKey = '<breez api key>'

const sdk = await connectWithSigner(
  config,
  signers.breezSigner,
  signers.sparkSigner,
  'breez_spark_db' // For WASM, this is the IndexedDB database name
)
React Native
const turnkeyConfig: TurnkeyConfig = {
  baseUrl: undefined,
  organizationId: '<turnkey sub-organization id>',
  apiPublicKey: '<api public key hex>',
  apiPrivateKey: '<api private key hex>',
  walletId: '<turnkey wallet id>',
  network: Network.Mainnet,
  accountNumber: undefined,
  // Set after the first connect to make later signer setup network-free
  identityPublicKey: undefined,
  retry: undefined,
  maxRps: undefined,
  proxy: undefined
}

const signers = await createTurnkeySigner(turnkeyConfig)

const config = defaultConfig(Network.Mainnet)
config.apiKey = '<breez api key>'

const sdk = await connectWithSigner({
  config,
  breezSigner: signers.breezSigner,
  sparkSigner: signers.sparkSigner,
  storageDir: `${RNFS.DocumentDirectoryPath}/data`
})
Python
turnkey_config = TurnkeyConfig(
    base_url=None,
    organization_id="<turnkey sub-organization id>",
    api_public_key="<api public key hex>",
    api_private_key="<api private key hex>",
    wallet_id="<turnkey wallet id>",
    network=Network.MAINNET,
    account_number=None,
    # Set after the first connect to make later signer setup network-free
    identity_public_key=None,
    retry=None,
    max_rps=None,
)

signers = await create_turnkey_signer(config=turnkey_config)

config = default_config(Network.MAINNET)
config.api_key = "<breez api key>"

sdk = await connect_with_signer(ConnectWithSignerRequest(
    config=config,
    breez_signer=signers.breez_signer,
    spark_signer=signers.spark_signer,
    storage_dir="./.data"
))
Go
turnkeyConfig := breez_sdk_spark.TurnkeyConfig{
	BaseUrl:        nil,
	OrganizationId: "<turnkey sub-organization id>",
	ApiPublicKey:   "<api public key hex>",
	ApiPrivateKey:  "<api private key hex>",
	WalletId:       "<turnkey wallet id>",
	Network:        breez_sdk_spark.NetworkMainnet,
	AccountNumber:  nil,
	// Set after the first connect to make later signer setup network-free
	IdentityPublicKey: nil,
	Retry:             nil,
	MaxRps:            nil,
}

signers, err := breez_sdk_spark.CreateTurnkeySigner(turnkeyConfig)
if err != nil {
	return nil, err
}

config := breez_sdk_spark.DefaultConfig(breez_sdk_spark.NetworkMainnet)
apiKey := "<breez api key>"
config.ApiKey = &apiKey

sdk, err := breez_sdk_spark.ConnectWithSigner(breez_sdk_spark.ConnectWithSignerRequest{
	Config:      config,
	BreezSigner: signers.BreezSigner,
	SparkSigner: signers.SparkSigner,
	StorageDir:  "./.data",
})
if err != nil {
	return nil, err
}

A few notes on the configuration:

  • api_private_keyapi_private_keyapiPrivateKeyapiPrivateKeyapiPrivateKeyapiPrivateKeyapiPrivateKeyApiPrivateKeyApiPrivateKey is a server secret. It authenticates every Turnkey request; keep it out of client code and logs.
  • The API key pair can be secp256k1 or P-256 (Turnkey's console default). All published bindings support both. If you use the Rust crate directly, enable the turnkey cargo feature, plus turnkey-p256 for P-256 keys.
  • max_rpsmax_rpsmaxRpsmaxRpsmaxRpsmaxRpsmaxRpsMaxRpsMaxRps paces requests to Turnkey. Unset uses Turnkey's documented limit of 10 requests per second per sub-organization; set it if your account has a different limit.

Reconnecting without network calls

Server deployments often build a fresh SDK instance per request. Setting identity_public_keyidentity_public_keyidentityPublicKeyidentityPublicKeyidentityPublicKeyidentityPublicKeyidentityPublicKeyIdentityPublicKeyIdentityPublicKey makes the signer setup network-free. Obtain it once from a freshly built signer with get_identity_public_keyget_identity_public_keygetIdentityPublicKeygetIdentityPublicKeygetIdentityPublicKeygetIdentityPublicKeygetIdentityPublicKeyGetIdentityPublicKeyGetIdentityPublicKey (available right after create_turnkey_signercreate_turnkey_signercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignerCreateTurnkeySignerCreateTurnkeySigner, no connect needed), or from identity_pubkeyidentity_pubkeyidentityPubkeyidentityPubkeyidentityPubkeyidentityPubkeyidentityPubkeyIdentityPubkeyIdentityPubkey on get_infoget_infogetInfogetInfogetInfogetInfogetInfoGetInfoGetInfo if you already have a connected SDK. Store it alongside the wallet and pass it in the config on later connects. It is a stable, non-secret value, but it must belong to the same wallet.

Without it, every signer setup also asks Turnkey to create the wallet's identity accounts. Once they exist, Turnkey records these requests as failed activities (the path already exists in the wallet account). The failures are expected and harmless, and setting identity_public_keyidentity_public_keyidentityPublicKeyidentityPublicKeyidentityPublicKeyidentityPublicKeyidentityPublicKeyIdentityPublicKeyIdentityPublicKey avoids them.

Wallets under a deny-export policy

If your Turnkey policy denies key exports, or allows only the static deposit key (see Key exports), use create_turnkey_signing_only_signercreate_turnkey_signing_only_signercreateTurnkeySigningOnlySignercreateTurnkeySigningOnlySignercreateTurnkeySigningOnlySignercreateTurnkeySigningOnlySignercreateTurnkeySigningOnlySignerCreateTurnkeySigningOnlySignerCreateTurnkeySigningOnlySigner instead: it never needs the encryption key. Connect its signers as described in Signers Without Local ECIES/HMAC Support, which lists the features a signing-only signer can't use. Bitcoin deposits still need the static deposit key: without its export, receive_paymentreceive_paymentreceivePaymentreceivePaymentreceivePaymentreceivePaymentreceivePaymentReceivePaymentReceivePayment fails for a Bitcoin address, so the SDK never issues an address it could not claim or refund.

Signing models

How payments are authorized is decided by the Turnkey policy attached to each credential, not by SDK code. Configure the policies in Turnkey; see the Turnkey policy documentation for the mechanics and Turnkey's Spark operations for the activity names the policy scopes.

Whichever model you choose, the credential also needs to create wallet accounts (CREATE_WALLET_ACCOUNTS): the SDK adds the accounts it needs to the wallet, such as the identity accounts and the accounts of the exported keys.

Server-side signing

The server's API credential is allowed to run all Spark signing activities. Every SDK flow then works as documented, with the exception listed under Availability, starting with Sending payments: the server prepares, signs and sends on its own. Use this when the server is trusted to send payments autonomously.

User-approved payments

The policy allows the server's credential to run everything except the transfer approval activity (SPARK_PREPARE_TRANSFER), which requires the end user's own Turnkey credential (for example a passkey registered with Turnkey). The server then drives the send with the Client signing flow: it prepares the payment and builds the package, the user approves and signs the one item that needs their credential, and the server publishes it. The rest of the signing (SPARK_SIGN_FROST) stays with the server under policy, so a payment can never be sent without the user, and no key leaves Turnkey on either side, except the static deposit key for Bitcoin deposits.

Key exports

Two keys leave the enclave, each exported with EXPORT_WALLET_ACCOUNT the first time a feature needs it:

KeyPathNeeded byUsed for
Static deposit keym/8797555'/<account>'/3'/0'both Turnkey signersBitcoin deposits: issuing the address, claiming and refunding
Encryption keym/8797555'/<account>'/2147483647'create_turnkey_signercreate_turnkey_signercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignerCreateTurnkeySignerCreateTurnkeySigner onlyReal-time sync (on by default), cross-chain payments and LNURL-auth

<account> is the wallet's account number: 1 on mainnet unless you set account_numberaccount_numberaccountNumberaccountNumberaccountNumberaccountNumberaccountNumberAccountNumberAccountNumber.

The static deposit key controls only funds sent to the wallet's Bitcoin deposit address. The encryption key controls no Spark funds, but it encrypts the data the SDK syncs and, with cross-chain payments, the secrets of swaps in progress.

Allowing only these exports

Allow these two paths in your policies rather than denying exports: in Turnkey a deny always overrides an allow, so a policy that denies EXPORT_WALLET_ACCOUNT also blocks these keys. Turnkey denies anything no policy allows, so every other key and the wallet seed stay in the enclave. Policies apply only to non-root users (Turnkey's root quorum bypasses them), so the server's credential must belong to a non-root user.

The static deposit key, needed with either signer. Turnkey's Spark policy examples publish the same policy without the account check:

{
  "policyName": "Allow exporting the static deposit key",
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<DELEGATED_USER_ID>')",
  "condition": "activity.kind == 'EXPORT_WALLET_ACCOUNT' && wallet_account.path_indexes.count() == 4 && wallet_account.path_indexes[0] == 8797555 && wallet_account.path_indexes[1] == 1 && wallet_account.path_indexes[2] == 3 && wallet_account.path_hardened.all(h, h == true)"
}

The encryption key, needed with create_turnkey_signercreate_turnkey_signercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignerCreateTurnkeySignerCreateTurnkeySigner:

{
  "policyName": "Allow exporting the Breez SDK encryption key",
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<DELEGATED_USER_ID>')",
  "condition": "activity.kind == 'EXPORT_WALLET_ACCOUNT' && wallet_account.path_indexes.count() == 3 && wallet_account.path_indexes[0] == 8797555 && wallet_account.path_indexes[1] == 1 && wallet_account.path_indexes[2] == 2147483647 && wallet_account.path_hardened.all(h, h == true)"
}

Replace <DELEGATED_USER_ID> with the id of the non-root Turnkey user that holds the server's credential, and the 1 in path_indexes[1] with your account number if it differs. Keep these conditions in policies of their own: a policy that reads the path fields matches only exports.

Availability

  • Turnkey signers are available on all platforms except Flutter, which does not support external signers (see Using an External Signer).
  • In the Rust crate the integration is behind the turnkey cargo feature. The published bindings ship with it enabled.
  • The watchtower fee ladder is not supported with a Turnkey signer. A ladder adds many signatures, which Turnkey bills one by one, and a leaf the operators defend with it can need the cooperative recovery described below. Setting watchtower_fee_ladder_enabledwatchtower_fee_ladder_enabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledWatchtowerFeeLadderEnabledWatchtowerFeeLadderEnabled to true fails to connect.
  • A Turnkey wallet cannot recover funds cooperatively yet: Turnkey does not support the signing round a cooperative recovery needs, so every RecoveryMethod::CooperativeRecoveryMethod.COOPERATIVERecoveryMethod.cooperativeRecoveryMethod.CooperativeRecoveryMethod.CooperativeRecoveryMethod.CooperativeRecoveryMethod.CooperativeRecoveryMethodCooperativeRecoveryMethod.Cooperative leaf comes back in failedfailedfailedfailedfailedfailedfailedFailedFailed with CooperativeRecoveryError::GenericCooperativeRecoveryError.GENERICCooperativeRecoveryError.genericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryErrorGenericCooperativeRecoveryError.Generic.