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:
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?;
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"
))
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
}
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"
));
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
)
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`
})
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"
))
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_keyapiPrivateKeyapiPrivateKeyapiPrivateKeyapiPrivateKeyapiPrivateKeyApiPrivateKeyApiPrivateKeyis 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
turnkeycargo feature, plusturnkey-p256for P-256 keys. max_rpsmax_rpsmaxRpsmaxRpsmaxRpsmaxRpsmaxRpsMaxRpsMaxRpspaces 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:
| Key | Path | Needed by | Used for |
|---|---|---|---|
| Static deposit key | m/8797555'/<account>'/3'/0' | both Turnkey signers | Bitcoin deposits: issuing the address, claiming and refunding |
| Encryption key | m/8797555'/<account>'/2147483647' | create_turnkey_signercreate_turnkey_signercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignercreateTurnkeySignerCreateTurnkeySignerCreateTurnkeySigner only | Real-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
turnkeycargo 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_enabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledwatchtowerFeeLadderEnabledWatchtowerFeeLadderEnabledWatchtowerFeeLadderEnabledto 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.Cooperativeleaf comes back infailedfailedfailedfailedfailedfailedfailedFailedFailedwithCooperativeRecoveryError::GenericCooperativeRecoveryError.GENERICCooperativeRecoveryError.genericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryError.GenericCooperativeRecoveryErrorGenericCooperativeRecoveryError.Generic.