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:
- Construct a validated request object using the appropriate Zod schema
- Encrypt it with
blockstream.request(payload)— produces a COSE-encoded byte array - Send the byte array to the
/requestendpoint, receive encrypted bytes back - Decrypt the response with
blockstream.parse(bytes)— returns a typed payload
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.
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.
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.
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:
| Status | Meaning |
|---|---|
success | Request completed. details contains the result. |
pending | Request queued for multi-party approval. A proposal was created. |
failed | Request rejected. message contains the reason. |
unauthorized | The session does not have permission. |
rejected | The 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.
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.
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 }.
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
| Single | Batch | Atomic Batch | |
|---|---|---|---|
| Round-trips | 1 per request | 1 for N requests | 1 for N requests |
| Execution order | — | Parallel | Sequential |
| Rollback on failure | — | No | Yes |
| Use case | Any single operation | Read multiple resources | Sequential writes |