Webhook icon
Request icon
If icon
Fail icon
Query icon
SlackIncomingWebhook icon
Exit icon
Write icon
LoopUntil icon
Pause icon

Multisig Bitcoin Treasury Spends with PSBT Co-Signing

Approve multisig Bitcoin spends with Kestra. Policy checks, PSBT signature collection with Pause, upload verification, fee caps, and an audit log.

Categories
BusinessInfrastructure

Teams that hold Bitcoin in a multisig wallet need a process around every spend: someone requests it, it is checked against policy, several people sign on their own devices, and nobody can slip in a different transaction along the way. This blueprint turns that process into a flow. Bitcoin Core runs a watch-only wallet with the multisig descriptor, so the server never holds a private key. Kestra builds the PSBT, pauses for each signer, verifies every upload, and broadcasts only when the transaction is fully signed and the node accepts it.

It defaults to signet and refuses to run on mainnet unless allow_mainnet is set.

How it works

  1. spend_request (Webhook) receives a request from your treasury app; manual runs use the inputs instead.
  2. node_check + mainnet_guard (If) stop the run if the node is unreachable or on mainnet without allow_mainnet.
  3. record_request logs the request in Postgres. Webhook values are bound as query parameters, never pasted into SQL.
  4. policy_check (If) rejects invalid addresses and amounts above max_spend_btc before any coins are reserved.
  5. create_psbt builds an unsigned PSBT with walletcreatefundedpsbt. lockUnspents reserves the coins, so two requests can never pick the same inputs.
  6. fee_guard (If) defers the spend and releases the coins when the estimated fee rate is above max_fee_rate.
  7. ask_signers posts the request, fee, and PSBT to Slack. The PSBT is also stored as a .psbt file in the execution outputs.
  8. collect_signatures (LoopUntil) pauses until a signer resumes the execution with a signed PSBT or a rejection. Each upload is checked:
    • it must decode as a PSBT for exactly the proposed transaction, otherwise it is refused and the flow keeps waiting
    • it is combined with the signatures collected so far
    • signers are identified from the key fingerprints in the PSBT, so uploads that add nothing new are reported as such
    • the loop ends when analyzepsbt reports the transaction complete
  9. finalize, test_accept, and broadcast: the node must accept the transaction in testmempoolaccept before it is sent.
  10. mark_broadcast and notify_broadcast record the transaction id and who signed.

Error handling

Every way a spend can stop leaves the treasury clean: no coins stay reserved and the request log says why.

  • Policy rejection, signer rejection, fee deferral end the run as WARNING with Exit. Each releases the reserved coins and posts the reason to Slack.
  • Bad uploads do not end the request. A paste that is not a PSBT, a PSBT for a different transaction, or a duplicate signature is reported to Slack, nothing is recorded, and the flow keeps waiting.
  • Wallet errors such as insufficient funds are surfaced with Bitcoin Core's own message ("Bitcoin Core could not build the transaction: Insufficient funds").
  • Expired requests. The signing window is 48 hours (pauseDuration with behavior: FAIL).
  • Every failure path. The flow-level errors block releases the coins using the inputs of the decoded PSBT, so it works even if Postgres is the problem. It then marks the request failed and alerts Slack with the failing task and cause.
  • Bad signatures cannot reach the network. testmempoolaccept must accept the finalized transaction before sendrawtransaction is called.

What this blueprint teaches about Kestra

  • Multi-party human-in-the-loop with Pause inside LoopUntil: each resume brings new data (onResume inputs) and the loop decides whether to wait again.
  • Exit for deliberate outcomes versus Fail and the errors block for real failures, with compensation (releasing coins) in both.
  • Bound SQL parameters on the JDBC tasks for untrusted webhook data.
  • storage.Write to hand a file to people from a paused execution.

Prerequisites

  • Bitcoin Core (v24 or newer) with a watch-only descriptor wallet holding your multisig descriptor, for example wsh(sortedmulti(2,[fp1/48h/1h/0h/2h]tpub.../0/*,[fp2/...]tpub.../0/*,[fp3/...]tpub.../0/*)), imported as active receive and change descriptors. Start on signet.
  • Signers with hardware wallets or software such as Sparrow or Electrum that can sign a base64 PSBT.
  • A Postgres database and a Slack incoming webhook.

Secrets

  • BITCOIN_RPC_USER, BITCOIN_RPC_PASSWORD: RPC credentials for the coordinating node.
  • POSTGRES_URL, POSTGRES_USER, POSTGRES_PASSWORD: JDBC URL and credentials for the request log.
  • SLACK_WEBHOOK_URL: Slack incoming webhook for requests, progress, and alerts.

Inputs

  • to_address, amount_btc, memo, requested_by: the request, for manual runs (webhook runs read the request body).
  • rpc_url, wallet: node endpoint and watch-only wallet.
  • signers (JSON): master key fingerprint to display name, for example {"f0783cf6": "Alice"}.
  • max_spend_btc (FLOAT, default 0.5), max_fee_rate (FLOAT, default 50 sat/vB), fee_conf_target (INT, default 6).
  • allow_mainnet (BOOL, default false), kestra_url (STRING): base URL for links in Slack.

Outputs

  • request_id and txid. The full history of every request, including who signed and why it ended, is in the spend_requests table.

Quick start

  1. Create the watch-only wallet on signet and import your multisig descriptor; fund it from a signet faucet.
  2. Add the secrets, set signers to your signers' fingerprints, and change the webhook key.
  3. Send a request: curl -X POST <kestra>/api/v1/main/executions/webhook/company.team/bitcoin-multisig-psbt-cosigning/treasury-spend-change-me -H 'Content-Type: application/json' -d '{"to_address": "tb1q...", "amount_btc": 0.001, "memo": "test", "requested_by": "me"}'
  4. Each signer loads the PSBT from Slack or the execution outputs, checks the address and amount on their device, signs, and pastes the signed PSBT into the resume form.
  5. Watch the transaction appear in the mempool once the quorum is reached.

How to extend

  • Require a specific signer for large amounts by checking record_signature.row.signed_by before finalize.
  • Send confirmations back to your app by calling it after mark_broadcast, or pair this flow with bitcoin-deposit-confirmation-tracker.
  • Store an approval note per signer by adding a column and writing onResume.note in record_signature.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.