Blockstream Enterprise

Making Requests

How to construct, encrypt, and send requests using the SDK — single requests and batches.

All communication with the Custody Engine goes through an encrypted channel. The SDK handles encryption and decryption via the Blockstream class. Your code only works with plain TypeScript objects — never raw bytes.

The Request Lifecycle

Every request follows the same three steps:

  1. Construct a validated request object using the appropriate Zod schema
  2. Encrypt it with blockstream.request(payload) — produces a COSE-encoded byte array
  3. Send the byte array to the /request endpoint, receive encrypted bytes back
  4. Decrypt the response with blockstream.parse(bytes) — returns a typed payload
Raw lifecycle
import { Blockstream, CreateWalletsRequestSchema } from '@blockstream/ecs-js-sdk'

const blockstream = new Blockstream(
  rsaPrivateKey,
  ecdsaPrivateKeyBytes,
  deviceUuid,
  rsaServerPublicKey,
  ecdsaServerPublicKeyBytes,
)

// 1. Construct and validate
const payload = CreateWalletsRequestSchema.parse({
  action: 'add',
  resource: '/wallets',
  details: {
    id: crypto.randomUUID(),
    name: 'my-wallet',
    type: 'amp',
    network: 'liquid',
    value: { signer_id: '<signer-id>' },
  },
})

// 2 + 3. Encrypt and send
const encrypted = await blockstream.request(payload)
const responseBytes = await fetch('/request', {
  method: 'POST',
  body: encrypted,
  headers: { 'Content-Type': 'application/octet-stream' },
}).then(r => r.arrayBuffer())

// 4. Decrypt
const response = await blockstream.parse(new Uint8Array(responseBytes))

Helper Functions

Copy this file into your project as a starting point. It implements the full encrypt → send → decrypt lifecycle and exposes broadcastRequest and batchBroadcast as typed wrappers.

broadcaster.ts
import { Blockstream } from '@blockstream/ecs-js-sdk'
import * as z from 'zod'

/**
 * Core encrypt → HTTP → decrypt cycle.
 * All higher-level helpers go through this.
 */
async function executeBroadcast<T>(
  payload: object,
  blockstream: Blockstream,
  opts?: { timeout?: number },
): Promise<T> {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), opts?.timeout ?? 60_000)

  try {
    const encrypted = await blockstream.request(payload)

    const res = await fetch('/request', {
      method: 'POST',
      body: encrypted,
      headers: { 'Content-Type': 'application/octet-stream' },
      signal: controller.signal,
    })

    if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)

    const bytes = await res.arrayBuffer()
    const result = await blockstream.parse<T>(new Uint8Array(bytes))
    return result.payload as T
  } catch (err) {
    if ((err as Error).name === 'AbortError') {
      throw new Error(`Request timed out after ${opts?.timeout ?? 60_000}ms`)
    }
    throw err
  } finally {
    clearTimeout(timeoutId)
  }
}

/**
 * Send a single request. Throws on `failed` status.
 * Pass `T` as a resource action entry from `resourceSchema` for full type inference.
 */
export async function broadcastRequest<T extends { request: z.ZodType; response: z.ZodType }>(
  jsonMessage: z.infer<T['request']>,
  blockstream: Blockstream,
  opts?: { timeout?: number },
): Promise<z.infer<T['response']>> {
  const payload = await executeBroadcast<z.infer<T['response']>>(
    jsonMessage as object,
    blockstream,
    opts,
  )

  if ((payload as { status: string }).status === 'failed') {
    throw new Error((payload as { message?: string }).message ?? 'Request failed')
  }

  return payload
}

/**
 * Send multiple requests in one round-trip.
 * Pass `{ isAtomic: true }` for sequential write operations that roll back on failure.
 */
export async function batchBroadcast<
  T extends ReadonlyArray<{ request: z.ZodType; response: z.ZodType }>,
>(
  messages: { [K in keyof T]: z.infer<T[K]['request']> },
  blockstream: Blockstream,
  opts?: { isAtomic?: boolean; timeout?: number },
): Promise<{ [K in keyof T]: z.infer<T[K]['response']> }> {
  type BatchResponse = {
    batch: z.infer<T[number]['response']>[]
    atomicBatch: {
      status: string
      message?: string
      details: z.infer<T[number]['response']>[]
    }
  }

  const batchPayload = opts?.isAtomic
    ? { batch: [], atomicBatch: messages as object[] }
    : { batch: messages as object[], atomicBatch: [] }

  const response = await executeBroadcast<BatchResponse>(batchPayload, blockstream, opts)

  if (opts?.isAtomic) {
    if (response.atomicBatch.status !== 'success') {
      throw new Error(
        response.atomicBatch.message ??
          `Atomic batch failed with status: ${response.atomicBatch.status}`,
      )
    }
    return response.atomicBatch.details as { [K in keyof T]: z.infer<T[K]['response']> }
  }

  return response.batch as { [K in keyof T]: z.infer<T[K]['response']> }
}

Single Request

The broadcastRequest helper wraps the full lifecycle into one call. It takes a validated request object and returns a typed response.

Signature
async function broadcastRequest<T extends { request: z.ZodType; response: z.ZodType }>(
  jsonMessage: z.infer<T['request']>,
  blockstream: Blockstream,
  opts?: { timeout?: number },
): Promise<z.infer<T['response']>>

The generic parameter T is a resource action entry from resourceSchema — it carries both the request and response types so TypeScript can infer everything end-to-end.

Example
import { CreateWalletsRequestSchema, resourceSchema } from '@blockstream/ecs-js-sdk'
import { broadcastRequest } from './broadcaster'

const response = await broadcastRequest<typeof resourceSchema.shape.wallets.shape.create>(
  {
    action: 'add',
    resource: '/wallets',
    details: {
      id: crypto.randomUUID(),
      name: 'my-wallet',
      type: 'amp',
      network: 'liquid',
      value: { signer_id: '<signer-id>' },
    },
  },
  blockstream,
)

if (response.status === 'success') {
  console.log(response.details)
}

Response statuses

Every response includes a status field. Check it before reading details:

StatusMeaning
successRequest completed. details contains the result.
pendingRequest queued for multi-party approval. A proposal was created.
failedRequest rejected. message contains the reason.
unauthorizedThe session does not have permission.
rejectedThe request was explicitly rejected by policy.

Batch Request

Send multiple independent requests in one encrypted round-trip. The server processes them in parallel and returns responses in the same order.

Signature
async function batchBroadcast<T extends ReadonlyArray<{ request: z.ZodType; response: z.ZodType }>>(
  messages: { [K in keyof T]: z.infer<T[K]['request']> },
  blockstream: Blockstream,
  opts?: { isAtomic?: boolean; timeout?: number },
): Promise<{ [K in keyof T]: z.infer<T[K]['response']> }>

The return type is a tuple that mirrors the input array — each element is typed to its own response schema.

Example
import { batchBroadcast } from './broadcaster'

const [wallet, signerList] = await batchBroadcast<
  [typeof resourceSchema.shape.wallets.shape.get, typeof resourceSchema.shape.signers.shape.list]
>(
  [
    { action: 'get', resource: '/wallets/w1', details: {} },
    { action: 'list', resource: '/signers', details: {} },
  ],
  blockstream,
)

Atomic Batch Request

Send multiple write operations that must execute sequentially. If any step fails, the whole batch is rolled back. Uses the same batchBroadcast function with { isAtomic: true }.

Example
import { batchBroadcast } from './broadcaster'

const [inviteRes, roleRes] = await batchBroadcast<
  [typeof resourceSchema.shape.users.shape.invite, typeof resourceSchema.shape.roles.shape.apply]
>(
  [
    { action: 'invite', resource: '/users', details: { email: 'user@example.com' } },
    { action: 'apply', resource: '/roles/admin', details: { user_id: '<user-id>' } },
  ],
  blockstream,
  { isAtomic: true },
)

Pass { isAtomic: true } in the options. The server executes operations in order and the entire batch is atomic.


Comparison

SingleBatchAtomic Batch
Round-trips1 per request1 for N requests1 for N requests
Execution orderParallelSequential
Rollback on failureNoYes
Use caseAny single operationRead multiple resourcesSequential writes

On this page