Blockstream Enterprise Custody SDK

Making Requests

How to send single, batch, and atomic batch requests with the built-in broadcaster helpers in the SDK, including Node-side mTLS transport when `/request` requires client certificates.

Complete Setup and Installation before using this page.

This guide assumes:

  • @blockstream/cryptic is installed and initialized
  • @blockstream/ecs-registry and zod are available for typed ECS schemas
  • your application already has deviceUuid, server public keys, and a working Blockstream instance
  • you want the default request transport first, with customization only where needed

If your deployment requires mTLS for /request, keep the message encryption flow the same and swap only the HTTP transport layer. Certificate provisioning lives in the browser-safe @blockstream/cryptic/mtls entrypoint, while the Node-only client-certificate request transport (mtlsRequest) lives in @blockstream/cryptic/mtls/node. The built-in Broadcaster can accept a transport override through requestCallback.

Request types at a glance

TypeRound-tripsExecution orderRollback on failureUsage
Single1 per requestOne operationNot applicableAny standalone read or write
Batch1 for many itemsIndependentNoUnrelated reads or writes you want to group
Atomic Batch1 for many itemsSequentialYesMulti-step writes that must succeed together

If you only need one operation, start with a single request. Use batch when you want fewer round-trips for unrelated work. Use atomic batch when later steps depend on earlier writes and partial success would be incorrect.

The common lifecycle

Every request follows the same four stages:

  1. Build a request object
  2. Encrypt and sign it with blockstream.request(...)
  3. Send the encrypted bytes to ${ECS_BASE_URL}/request using either normal HTTPS or mTLS
  4. Decrypt the response with blockstream.parse(...)
Raw request lifecycle
import { Buffer } from 'buffer'
import { Blockstream } from '@blockstream/cryptic'
import { CreateWalletRequestSchema } from '@blockstream/ecs-registry'

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

const encrypted = await blockstream.request(payload)

const responseBytes = await fetch(`${ECS_BASE_URL}/request`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/octet-stream' },
  body: Buffer.from(encrypted),
}).then(r => r.arrayBuffer())

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

For environments that require client certificates, the request payload is still produced by blockstream.request(...) and the response is still parsed by blockstream.parse(...). Only the HTTPS transport changes.

Most applications should not build their own request helper first. The SDK already ships a broadcaster that handles:

  • request encryption and response decryption
  • posting encrypted bytes to ${ECS_BASE_URL}/request
  • timeout handling
  • common success and failure status handling
Create one broadcaster for a session
import { Buffer } from 'buffer'
import { Blockstream } from '@blockstream/cryptic'
import { Broadcaster } from '@blockstream/cryptic/broadcaster'

const blockstream = new Blockstream(
  clientRsaPrivateKeyPem,
  Buffer.from(clientEcdsaPrivateKeyHex, 'hex'),
  deviceUuid,
  serverRsaPublicKeyPem,
  Buffer.from(serverEcdsaPublicKeyHex, 'hex'),
)

const broadcaster = new Broadcaster(ECS_BASE_URL, blockstream)

If your app already has a Blockstream instance for the current device or user, adding request transport is usually just one extra line.

The built-in Broadcaster is the recommended default when /request is available over your normal HTTPS path. If your environment requires mTLS, keep the same Broadcaster API and replace only the HTTP transport callback from Node or another trusted runtime.

/request with mTLS

Use this when your ECS deployment requires a client certificate for the /request endpoint.

The transport flow becomes:

  1. Build the encrypted payload with blockstream.request(...)
  2. Provision or load an mTLS client identity in Node
  3. Send the /request body through a broadcaster requestCallback that uses mtlsRequest(...)
  4. Parse the response with blockstream.parse(...)
Broadcaster with Node-side mTLS transport
import { Buffer } from 'buffer'
import { Blockstream } from '@blockstream/cryptic'
import { Broadcaster } from '@blockstream/cryptic/broadcaster'
import { provisionMtlsClientCertificate, PROD_CA_CERT_PEM, buildMtlsSecret, MtlsProfile, MtlsSecretType } from '@blockstream/cryptic/mtls'
import { mtlsRequest } from '@blockstream/cryptic/mtls/node'

const blockstream = new Blockstream(
  clientRsaPrivateKeyPem,
  Buffer.from(clientEcdsaPrivateKeyHex, 'hex'),
  deviceUuid,
  serverRsaPublicKeyPem,
  Buffer.from(serverEcdsaPublicKeyHex, 'hex'),
)

const ecdsaPublicKey = Buffer.from(
  p256.getPublicKey(
    new Uint8Array(Buffer.from(clientEcdsaPrivateKeyHex, 'hex')),
    true,
  ),
)
const secret = buildMtlsSecret(ecdsaPublicKey)

const identity = await provisionMtlsClientCertificate('https://enterprise.blockstream.com', {
  profile: MtlsProfile.MtlsClientV1,
  deviceUuid,
  secret,
  secretType: MtlsSecretType.PubKey,
})

const broadcaster = new Broadcaster(ECS_BASE_URL, blockstream, async request => {
  const response = await mtlsRequest({
    url: request.url,
    method: request.method,
    headers: request.headers,
    body: request.body,
    certPem: identity.certificatePem,
    keyPem: identity.privateKeyPem,
    caPem: PROD_CA_CERT_PEM,
  })

  return {
    status: response.status,
    body: response.body,
  }
})

const result = await broadcaster.processBroadcast({
  action: 'get',
  resource: '/wallets',
})

Notes:

  • Number of mTLS certificates are strictly limited (3 per month). Store them securely and reuse until they expire.
  • Provisioning (@blockstream/cryptic/mtls) is browser-safe; the client-certificate request transport mtlsRequest (@blockstream/cryptic/mtls/node) is Node-only.
  • In Electron, do the mTLS transport in main or preload, not in page JS.
  • mTLS only changes the TLS handshake. The ECS request body is still the same encrypted COSE payload.
  • If you need per-request variation, requestCallback is also available in BroadcastOptions on the standalone broadcaster helpers.

The browser-safe / Node split shown above lands in @blockstream/cryptic 1.0.4. On earlier versions the entire mTLS API was a single Node-only @blockstream/cryptic/mtls entrypoint — import everything (including mtlsRequest) from there:

Legacy import (@blockstream/cryptic < 1.0.4)
import { mtlsRequest, provisionMtlsClientCertificate, PROD_CA_CERT_PEM, buildMtlsSecret, MtlsProfile, MtlsSecretType } from '@blockstream/cryptic/mtls'

Single requests

Use processBroadcast(...) or processBroadcastSafe(...) for any standalone operation:

  • create one wallet
  • fetch one user
  • edit one policy
  • list one collection
import { ecsResources } from '@blockstream/ecs-registry'

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

Response statuses

Most ECS responses include a status field:

StatusMeaning
successRequest completed and details contains the result
pendingRequest became a proposal and requires approval
failedRequest was rejected and message explains why
unauthorizedThe session lacks permission
rejectedThe request was explicitly rejected by policy or review outcome

Batch requests

Use processBatchBroadcast(...) when the operations are independent and you want to reduce transport overhead.

{
  "batch": [],
  "atomicBatch": [],
}
  • batch items are independent
  • failures in one batch item do not roll back the others
  • responses come back in the same order as the inputs
import { ecsResources } from '@blockstream/ecs-registry'

const batchSchemas = [ecsResources.users.list, ecsResources.wallets.list] as const

const responses = await broadcaster.processBatchBroadcast([
  {
    action: 'get',
    resource: '/users',
    details: { limit: 20 },
  },
  {
    action: 'get',
    resource: '/wallets',
    details: { limit: 20 },
  },
])

If you want the returned batch array to preserve the exact response type at each tuple position, pass the schema tuple as the generic parameter:

import { ecsResources } from '@blockstream/ecs-registry'

const batchSchemas = [ecsResources.users.list, ecsResources.wallets.list] as const

const responses = await broadcaster.processBatchBroadcast<typeof batchSchemas>([
  {
    action: 'get',
    resource: '/users',
    details: { limit: 20 },
  },
  {
    action: 'get',
    resource: '/wallets',
    details: { limit: 20 },
  },
])

const usersResponse = responses.batch[0]
const walletsResponse = responses.batch[1]

That gives you typed tuple entries out of the box, without rebuilding your own wrapper just to recover details.

Atomic batch requests

Use processAtomicBatchBroadcast(...) when the writes belong together and partial success would leave the system in the wrong state.

Typical examples:

  • create several related resources that must either all exist or none exist
  • run dependent writes in a fixed order
  • enforce application-level transactional behavior across multiple ECS operations
import { ecsResources } from '@blockstream/ecs-registry'

const atomicSchemas = [ecsResources.groups.create, ecsResources.rules.create] as const

const responses = await broadcaster.processAtomicBatchBroadcast([
  {
    action: 'add',
    resource: '/groups',
    details: {
      gid: crypto.randomUUID(),
      name: 'treasury',
    },
  },
  {
    action: 'add',
    resource: '/rules',
    details: {
      rlid: crypto.randomUUID(),
      name: 'view-wallets',
      resource: '/wallets',
      action: 'get',
      description: 'Allow treasury group to list wallets',
    },
  },
])

If any step in the atomic batch fails, the full atomic section is treated as failed.

Just like plain batch requests, atomic batches can preserve tuple item types when you provide the schema tuple:

import { ecsResources } from '@blockstream/ecs-registry'

const atomicSchemas = [ecsResources.groups.create, ecsResources.rules.create] as const

const responses = await broadcaster.processAtomicBatchBroadcast<typeof atomicSchemas>([
  {
    action: 'add',
    resource: '/groups',
    details: {
      gid: crypto.randomUUID(),
      name: 'treasury',
    },
  },
  {
    action: 'add',
    resource: '/rules',
    details: {
      rlid: crypto.randomUUID(),
      name: 'view-wallets',
      resource: '/wallets',
      action: 'get',
      description: 'Allow treasury group to list wallets',
    },
  },
])

if (responses.atomicBatch.status === 'success') {
  const createGroupResponse = responses.atomicBatch.details[0]
  const createRuleResponse = responses.atomicBatch.details[1]
}

Choosing the right request type

  • Use a single request when clarity matters more than transport optimization
  • Use batch when the operations do not depend on one another
  • Use atomic batch when order and all-or-nothing behavior matter

Advanced: custom broadcaster patterns

If the built-in Broadcaster fits your needs, use it directly. Reach for a custom layer only when you need application-specific naming, retries, tracing, or transport overrides.

You no longer need a custom wrapper just to get typed batch or atomic details responses. The built-in batch APIs support that directly through their generic schema tuple parameter.

Wrap the SDK Broadcaster

This is the simplest customization pattern. You keep the SDK transport logic but expose app-specific helpers.

app/broadcaster.ts
import type { Blockstream } from '@blockstream/cryptic'
import { Broadcaster, type BroadcastOptions } from '@blockstream/cryptic/broadcaster'
import * as z from 'zod'

const broadcaster = new Broadcaster(ECS_BASE_URL, blockstream)

export function broadcastRequest<
  T extends {
    request: z.ZodType
    response: z.ZodType
  },
>(
  jsonMessage: z.infer<T['request']>,
  opts?: BroadcastOptions,
  sessionBlockstream?: Blockstream,
): Promise<z.infer<T['response']>> {
  return broadcaster.processBroadcast<T>(jsonMessage, opts, sessionBlockstream)
}

Use this when you want a stable application API without reimplementing the transport layer.

Use the direct helper exports

If you do not want to keep a Broadcaster instance around, the package also exports standalone helpers:

import {
  executeBroadcast,
  processBroadcast,
  processBatchBroadcast,
  processAtomicBatchBroadcast,
} from '@blockstream/cryptic/broadcaster'

const user = await processBroadcast(
  {
    action: 'get',
    resource: '/users/me',
  },
  blockstream,
  ECS_BASE_URL,
)

Use these when dependency injection or short-lived helpers fit your codebase better than a shared class instance.

Build your own broadcaster from scratch

If you need a fully custom transport layer, build it on top of blockstream.request(...) and blockstream.parse(...):

import type { Blockstream } from '@blockstream/cryptic'

async function executeBroadcast<T>(payload: object, blockstream: Blockstream): Promise<T> {
  const encrypted = await blockstream.request(payload)

  const responseBytes = await fetch(`${ECS_BASE_URL}/request`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/octet-stream' },
    body: encrypted,
  }).then(r => r.arrayBuffer())

  const response = await blockstream.parse<T>(new Uint8Array(responseBytes))
  return response.payload
}

Use this only when you need behavior the built-in broadcaster does not provide, such as a non-default fetch pipeline, custom retry policies, or deep observability hooks.

This is also the right pattern when /request requires mTLS. In that case, either inject requestCallback into Broadcaster or replace the raw fetch call with mtlsRequest(...) from @blockstream/cryptic/mtls/node.

On this page