Blockstream Enterprise Custody SDK
General Concepts

Transaction Lifecycle

How spend requests flow through Blockstream Enterprise Custody - from creation through policy evaluation to execution.

All transactions in Blockstream Enterprise Custody follow a governance-driven workflow. Instead of executing immediately on signing, every spend request passes through the Policy Engine first.

Why governance-driven transactions?

Traditional wallets execute transactions the moment a key signs. Blockstream Enterprise Custody introduces a policy layer between request and execution:

Traditional:          User Signs -> Transaction Executes
Enterprise Custody:   User Requests -> Policy Evaluates -> [Approval Process] -> Transaction Executes

This separation enables:

CapabilityDescription
Fraud preventionLarge or unusual transactions can require additional approval
ComplianceEnforce KYC/AML/travel rule requirements before execution
Operational controlSeparate requesters from approvers
Audit trailEvery transaction has a documented approval chain
Risk managementSpending limits, whitelists, and time-based restrictions via policies

The lifecycle

  1. Create spend request - Initiate a transaction; UTXOs are locked immediately
  2. Policy evaluation - The Policy Engine evaluates policies in priority order
  3. Decision - ALLOW (execute immediately), DENY (reject + unlock), or REVIEW (create proposal)
  4. Approval process - If REVIEW: designated reviewers approve or reject via proposals
  5. Execution - Transaction is signed and broadcast once all conditions are met
alt [Policy Decision: ALLOW] [Policy Decision: DENY] [Policy Decision: REVIEW] loop [Until K-of-N requirement met] POST /wallets/:id/spend-requests Lock UTXOs Broadcast transaction status: success Unlock UTXOs status: denied status: pending, proposal_id POST /proposals/:id/review Check review_expression All approvals received Sign and broadcast transaction Unlock remaining UTXOs Proposal executed Step 2: Policy Evaluation Step 3: Approval Process Step 4: Execution User Server Reviewers Blockchain Policy Engine

Spend request states

Create spend request Submit to policy engine ALLOW decision DENY decision REVIEW decision All approvals received Reviewer rejects Creator cancels Transaction broadcast Broadcast error UTXOs unlocked UTXOs unlocked UTXOs unlocked Complete Created PolicyEval Processed Failed Pending Approved Rejected Canceled

UTXO locking

When a spend request is created, the system immediately locks the UTXOs (Unspent Transaction Outputs) selected for that transaction. This prevents double-spending during the approval window:

Before request:  [UTXO-A: 50,000] [UTXO-B: 30,000] [UTXO-C: 20,000]
                    Available        Available        Available

After request:   [UTXO-A: 50,000] [UTXO-B: 30,000] [UTXO-C: 20,000]
(spending 70k)      LOCKED           LOCKED          Available

After approval:  [UTXO-D: 28,500]  (change output - new UTXO)
                    Available

UTXOs are automatically unlocked if the proposal is rejected or canceled.

Checking status

// Check spend request status
const spendRequest = await broadcastRequest({
  action: 'get',
  resource: `/spend-requests/${spendRequestId}`,
  details: {},
})
console.log(spendRequest.details.status) // pending | processed | failed

// Check proposal status
const proposal = await broadcastRequest({
  action: 'get',
  resource: `/proposals/${proposalId}`,
  details: {},
})
console.log(proposal.details.status) // pending | approved | executed | rejected | denied | canceled

Spend request statuses

StatusDescription
pendingWaiting for policy approval - proposal created
processedTransaction successfully broadcast and confirmed
failedTransaction failed (broadcast error, insufficient funds, etc.)

Proposal statuses

StatusDescription
pendingWaiting for reviewer approvals
approvedAll required approvals received, executing
executedTransaction broadcast successfully
rejectedRejected by a reviewer
deniedFailed policy validation
canceledCanceled by the creator

Polling for completion

async function waitForSpendRequest(
  spendRequestId: string,
  targetStatus: 'processed' | 'failed',
  maxRetries = 60,
  delayMs = 2000,
) {
  for (let i = 0; i < maxRetries; i++) {
    const result = await broadcastRequest({
      action: 'get',
      resource: `/spend-requests/${spendRequestId}`,
      details: {},
    })

    if (result.details.status === targetStatus || result.details.status === 'failed') {
      return result.details
    }

    await new Promise(resolve => setTimeout(resolve, delayMs))
  }

  throw new Error('Timeout waiting for spend request')
}

On this page