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/crypticis installed and initialized@blockstream/ecs-registryandzodare available for typed ECS schemas- your application already has
deviceUuid, server public keys, and a workingBlockstreaminstance - 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
| Type | Round-trips | Execution order | Rollback on failure | Usage |
|---|---|---|---|---|
| Single | 1 per request | One operation | Not applicable | Any standalone read or write |
| Batch | 1 for many items | Independent | No | Unrelated reads or writes you want to group |
| Atomic Batch | 1 for many items | Sequential | Yes | Multi-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:
- Build a request object
- Encrypt and sign it with
blockstream.request(...) - Send the encrypted bytes to
${ECS_BASE_URL}/requestusing either normal HTTPS or mTLS - Decrypt the response with
blockstream.parse(...)
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.
Recommended default: built-in Broadcaster
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
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:
- Build the encrypted payload with
blockstream.request(...) - Provision or load an mTLS client identity in Node
- Send the
/requestbody through a broadcasterrequestCallbackthat usesmtlsRequest(...) - Parse the response with
blockstream.parse(...)
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 transportmtlsRequest(@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,
requestCallbackis also available inBroadcastOptionson 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:
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:
| Status | Meaning |
|---|---|
success | Request completed and details contains the result |
pending | Request became a proposal and requires approval |
failed | Request was rejected and message explains why |
unauthorized | The session lacks permission |
rejected | The 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": [],
}batchitems are independent- failures in one
batchitem 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.
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.
Related guides
Setup and Installation
Canonical setup guide for the Blockstream Enterprise Custody JavaScript SDK, including registry access, package selection, key generation, and first-time SDK initialization.
Supported Chains
Which blockchain networks Blockstream Enterprise Custody supports, what wallet types are available on each, and when to use them.