Overview
A streaming route answers with a stream of server-sent events instead of one body. A deployment reports progress across several minutes, a transaction moves through states, and an activity feed has no last item, so their routes stream.
In the SDK they are ordinary nodes on the same tree. What differs is what comes back: an async iterable of frames, one decoded frame per server-sent event, instead of a promise of one payload.
for await (const frame of dalp.api.transactionsV2.statusStream({ transactionId })) {
console.log(frame);
}Reading a stream
Frames arrive in order and are parsed as JSON when they carry JSON.
async function watchDeployment(deploymentId: string) {
for await (const frame of dalp.api.platform.organizations.organizationsDeployStream({ deploymentId })) {
render(frame);
}
// The loop ends when the platform closes the stream.
}When the platform ends a stream with its reserved failure event, the loop throws a DalpApiError with the code sdk.stream.FAILED and retryable true. A client whose fetch cannot expose a streamed response body throws sdk. instead, and that error is not retryable. React Native covers the fetch that fixes it.
Breaking out of the loop closes the connection. So does an early return or a thrown error inside the body, because the iterator's cleanup runs either way and cancels the underlying read. You do not need to close anything by hand.
Cancelling
Pass an AbortSignal the same way you would on any other call:
const controller = new AbortController();
for await (const frame of dalp.api.assetPortfolioV2.v2.actions.stream({}, { signal: controller.signal })) {
render(frame);
}In React, abort in the effect's cleanup so a component that unmounts mid-stream does not hold a connection open:
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
for await (const frame of dalp.api.assetPortfolioV2.v2.actions.stream({}, { signal: controller.signal })) {
setLatest(frame);
}
} catch (error) {
if (!controller.signal.aborted) setError(error);
}
})();
return () => controller.abort();
}, []);The aborted check matters. An aborted stream rejects with the abort reason, and treating your own cleanup as a failure is how an unmount shows an error toast.
What the SDK does not do
It does not reconnect. One call opens one connection. When the stream ends, it has ended, and if it ended before the terminal event you were waiting for, that is a fact your code sees rather than a gap it never learns about.
If the connection ends before a terminal event, read the current status before deciding whether to reconnect. A missed event does not establish the transaction outcome.
The deployment, invitation, migration and token-creation streams send a complete frame only with the run's real verdict. When the server cannot read the run's progress, or no run is recorded for the id, the stream ends with its declared failure event instead. The deployment, invitation and migration streams wait up to two minutes for a run to appear before they end this way. On that event the SDK throws an error with the code sdk.stream.FAILED. That error means the outcome is not known, not that the run failed. Read the status route, then open the stream again if the run is still going.
On a settlement stream, a reconnect that resumed from the live edge would miss a COMPLETED sent while the connection was down, and the caller would wait forever. A status stream therefore sends the current row as its first frame on every subscription instead of resuming from a cursor. Use the stream to watch progress and the status route for the verdict. A status stream also closes after 180 seconds while the transaction is still in flight. Reopen it, and its first frame is the current state.
It does not classify frames. A frame is data. Whether a given frame is terminal, and whether terminal means success, is domain knowledge the route defines and your code applies.
Reconnecting
When you want a stream that survives a dropped connection, wrap the call. The SDK ships the wrapper the Console uses:
import { consumeSseUntilTerminal, SseLifecycleError } from "@settlemint/dalp-sdk";
const outcome = await consumeSseUntilTerminal({
connect: async (signal) => ({
stream: dalp.api.transactionsV2.statusStream({ transactionId }, { signal }),
}),
classify: (frame) => {
if (frame.family !== "evm") {
if (frame.submissionStatus === "settled") return "success";
return frame.submissionStatus === "failed" || frame.submissionStatus === "expired" ? "failure" : "continue";
}
if (frame.status === "COMPLETED") return "success";
// A dead letter that may still land is not an answer yet: keep following it.
if (frame.status === "DEAD_LETTER" && frame.mayStillLand === true) return "continue";
if (frame.status === "FAILED" || frame.status === "DEAD_LETTER" || frame.status === "CANCELLED") {
return "failure";
}
return "continue";
},
signal: outerSignal,
});It reconnects with backoff while frames are non-terminal, stops on the verdict classify returns, and throws SseLifecycleError when it gives up, with a reason distinguishing an abort from a terminal failure from a transport failure.
The reconnect budget is a wall-clock window rather than a count of attempts. When every failure is instant, a fixed count is spent in a few seconds, so a backend that stalls for longer exhausts the budget before it has recovered. The default window is 60 seconds. It is anchored at the first failure of a run, and restarts only once a connection has stayed up for 20 seconds, which counts as recovered. The wait between attempts starts at 250 milliseconds, doubles each time, and stops growing at 5 seconds. reconnectWindowMs, healthyConnectionMs, initialBackoffMs, and maxBackoffMs change these defaults, and maxReconnects replaces the window with a plain attempt count. A long-lived stream that dies late therefore gets a full window, rather than the remains of one spent hours earlier.
Receiving events does not count as recovery, because a stream that replays its opening frame on every subscription would then clear the failure streak on each attempt and reconnect forever.
Which routes stream
A streaming route is typed as returning an async iterable, so awaiting one as a body is a type error. Streams exist for organisation and invitation deployment progress, migration progress, transaction status, the action inbox, API request logs, chain-health snapshots, token creation, the trading venue's market and operator order feeds, and custody account-request lifecycle.
The full list, with each route's frames, is in the API reference, where every streaming route is documented beside the routes that return a body.
Funding requirement roles in stream frames
The deployment, invitation, migration and token-creation streams can carry a funding requirement: a wallet that needs gas before the work can go on. A frame with type set to requirement-required carries a role at frame.payload.role. The role is the funding purpose of the requirement. The wallet that is short is frame.payload.address. Several requirements can share one role, so do not key requirements by role alone. A requirement-resolved frame has no role, so narrow on frame. before you read frame.payload.role.
The stream sends a requirement-required frame again, under the same requirementId, when the requirement's amounts change or when the balance the wallet already holds, frame., changes. Replace the requirement you hold with the newer frame.
The set of roles can grow, and the published API document lists the roles as a closed enum. The SDK checks every stream frame against that enum. A frame with a role that your SDK version does not know throws sdk. before your loop sees the frame. The route methods of the SDK have no option to skip this check. You have two ways to handle a new role:
- Upgrade the SDK when a new role ships. Each SDK release is built from the API document of that release.
- Call the stream route yourself with an HTTP client. Send the same URL, credentials, and
Accept: text/header that the SDK sends, then pass the response body (aevent- stream Readable) toStream<Uint8 Array> decodeEventStreamwithout a frame decoder. It parses each frame as JSON and does not check theenum. You handle request errors yourself on this path.
If you decode frames without the enum check, handle an unknown role like this:
- Read
roleas a string. - Use the known values only to choose a label.
- Show an unknown role as a generic funding requirement. Never drop the row. A wallet that is short is still short, and the operator needs to see it.
The funding requirements list route, GET /api/, publishes role as a nullable open string. A stored requirement with no known role carries null, so treat null like an unknown role: show it as a generic funding requirement.
Next
Wiring calls into a React application: TanStack Query.