Skip to main content

Mutations and settlement

A write that touches the chain returns the settled resource or the platform's 202 handle. Retry safety, the acting context, and errors are yours to handle.

Overview

Minting, transferring, freezing, and voting submit on-chain transactions. API acceptance and ledger settlement are separate events. The SDK asks the platform to wait for settlement and returns a settled result or a pending handle.

Show a pending handle as pending. Resubmitting an instruction can create a duplicate transfer, even with the original idempotency key when the API route does other work before queue admission. Keep the handle and follow its status until the platform reports a terminal outcome.

Set the wait duration, acting identity, and retry policy for your application. The SDK sends the settlement preference and returns the platform response. It does not poll on your behalf.

A write is one call

const result = await dalp.api.token.transfer({
  assetId: "0x1234...",
  transfers: [{ recipient: "0xabcd...", amount: "1000" }],
});

If settlement completes within the requested wait, result contains the route result. Otherwise, it contains a pending handle.

Settlement

The platform decides whether a write settles inside the requested wait.

A queue-backed write carries a Prefer header that the SDK derives from settlementTimeoutMs, as Choosing the wait describes. The platform does the waiting. The SDK sends the header, reads back whatever status comes, and decodes the body.

If the write settles inside that wait, the response is 200 with the route's declared resource. If it does not, the response is 202 with a handle, and the call resolves with that handle instead. The SDK never polls a status URL on your behalf, and a 202 reaching your code is not a failure: the platform has the instruction and is still working on it.

Following a handle yourself

The handle's shape says which kind of write produced it. A transaction write hands back { transactionId, status, statusUrl }. A custody lifecycle write hands back { requestId, state, statusUrl }. Branch on which key the body carries, not on a status code the SDK does not expose.

The loop below follows a transaction handle over the status stream, which a client that authenticates with an API key can always open. A client that authenticates with a session cookie must send the request from the same origin or from an origin the deployment trusts. Any other cookie-authenticated request receives 404 before the first event. Use waitForTransaction or repeated dalp.api.transactionsV2.status({ transactionId }) reads when the browser cannot open the stream.

const TERMINAL = new Set(["COMPLETED", "FAILED", "DEAD_LETTER", "CANCELLED"]);

const result = await dalp.api.token.transfer({ assetId: tokenAddress, transfers });

if ("transactionId" in result) {
  // Still queued. Follow it, or resume later with the same id.
  const { transactionId } = result;
  let terminal;
  while (terminal === undefined) {
    for await (const frame of dalp.api.transactionsV2.statusStream({ transactionId })) {
      if (frame.family !== "evm") {
        // Another network family sends one frame and closes. Read its submissionStatus.
        terminal = frame;
        break;
      }
      // A dead letter that may still land is not an answer yet: keep following it.
      if (TERMINAL.has(frame.status) && frame.mayStillLand !== true) {
        terminal = frame;
        break;
      }
    }
    // Falling out without a terminal frame is the stream's 180-second
    // lifetime, not an answer. Reopen; the first frame is the current state.
    if (terminal === undefined) {
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  }
} else {
  // Already settled.
}

The stream can close after 180 seconds while the transaction remains pending. Reopen it to receive the current state. Wait between attempts so an immediate close cannot create a tight reconnect loop.

waitForTransaction(client, transactionId), from @settlemint/dalp-sdk/wait-for-transaction, follows one handle without the stream. It polls the status route, so it has no 180-second close to handle, and it works for a caller that authenticates with a session cookie from an origin the deployment does not trust, which the stream refuses. Prefer it unless you want each intermediate frame. A custody lifecycle handle has no such helper: follow it with a GET on statusUrl, and read state until it reaches a terminal value.

Do not resubmit while a handle is outstanding, because the instruction is live and a repeated API request can perform a second effect before it reaches the transaction queue. The exception is a route whose contract tells you to retry with the same Idempotency-Key, such as a corporate action commit that is still executing.

A DEAD_LETTER status with mayStillLand: true is not terminal yet. The platform could not confirm the call, and the call can still reach the chain, so the stream stays open on it. waitForTransaction keeps polling such a row and ends with reason timeout, not terminal-failure. Do not resend while mayStillLand is true.

Inspect the terminal status and failure details before deciding whether to submit another instruction. FAILED, CANCELLED, and DEAD_LETTER require different responses. A rejected input needs correction, a cancellation needs an authorised decision, and exhausted retries need investigation. Read the transaction-request record through the returned status URL to determine the next step.

An organisation owner or a platform administrator can requeue a FAILED or DEAD_LETTER request under the same transaction id, and can move an active request to FAILED with a reason. Keep watching a failed request's status before you send a replacement instruction, because a requeued attempt can still settle.

A write waits when a wallet it needs cannot pay the network fee, called gas. The platform parks the write until that wallet is funded, and the write reads as still queued meanwhile. Its status row carries the open funding requirement as activeRequirement. waitForTransaction restarts its timeout on every poll that reports one, so it keeps waiting for as long as the requirement stays open. Pass an AbortSignal for a hard limit, or holdWhilePaused: false to stop with reason funding-paused. A caller reading statusUrl directly sees the same field and can apply the same rule.

Choosing the wait

When settlementTimeoutMs is not set, the SDK asks the platform to hold the response for 15 seconds. Zero or a negative number sends respond-async, which returns the handle immediately with no hold at all. Any other value asks for that many milliseconds. The SDK rounds the request up to whole seconds and clamps it to between 5 and 99, and the platform applies the same 5 to 99 second range to the Prefer header it receives.

const dalp = createDalpClient({ baseUrl, runtime: "browser", settlementTimeoutMs: 30_000 });

or per call:

await dalp.api.token.transfer({ assetId: tokenAddress, transfers }, { settlementTimeoutMs: 15_000 });

The mutation factories set a 30-second bound of their own, for a mutation hook that holds a spinner while it waits.

A short wait does not fail the write. It returns the handle sooner. The outcome, including a failure, then reaches you only through the handle's status, so keep the handle.

Retry safety

Send an Idempotency-Key on any write your client might retry. On a route that documents replay safety and echoes the key, such as token.transfer, a retry with the same key after a timeout or a dropped connection is a retry of that one instruction. On any other route, a repeated request can act a second time before the instruction reaches the queue, so read the original operation's status before you send it again.

await dalp.api.token.transfer(
  { assetId: tokenAddress, transfers },
  { headers: { "Idempotency-Key": crypto.randomUUID() } }
);

The header contract, retention window, and duplicate handling are on Idempotency and async settlement. Two things are specific to the SDK.

A key identifies one instruction, not one call site. If a user submits the same form twice deliberately, those are two instructions and want two keys. If your code retries one submission, that is one instruction and wants one key across both attempts.

Reuse a key for every retry of one instruction on a replay-safe route. Generate it before the request and keep it until the instruction has a known outcome:

const idempotencyKey = crypto.randomUUID();
const options = { headers: () => ({ "Idempotency-Key": idempotencyKey }) };

Reuse options for retries of the same submission on a replay-safe route, and read the transaction status first after a dropped connection.

The acting context on a write

A write needs to know who it acts for and which wallet signs. The client-level context covers the steady state. A single call overrides it through headers.

await dalp.api.token.transfer({ assetId: tokenAddress, transfers }, { headers: { "X-Executor": "smart-wallet" } });

The field names, their headers, and when each applies are in Install and configure. On a multisig, X-Selected-Signers names which signers this submission counts toward. The platform accepts only signers whose keys the caller controls in the organisation and refuses any other. Naming the wrong one of your own signers produces a valid submission that counts toward that signer.

Errors

Any non-2xx response throws a DalpApiError. It carries the platform's error contract rather than a status code you then look up.

import { isDalpApiError } from "@settlemint/dalp-sdk";

try {
  await dalp.api.token.transfer({ assetId: tokenAddress, transfers });
} catch (error) {
  if (isDalpApiError(error)) {
    error.id; // stable DALP-NNNN identifier, when the platform supplied one
    error.code; // the machine-readable code
    error.status; // the HTTP status
    error.retryable; // whether retrying this exact request can succeed
    error.why; // what happened, in a sentence
    error.fix; // what to do about it
    error.retryAfterSeconds; // present when the platform asked you to wait
  }
  throw error;
}

Branch on error.id rather than on the message. The identifier is a stable contract and the message is prose that can be reworded. The identifiers and what each means are in the error reference.

Check retryable before retrying. Use the error identifier and recovery guidance to decide what to correct, and respect retryAfterSeconds when present. Keep retries bounded. For an uncertain write, inspect the original operation's state. Repeat the API request with its original idempotency key only when the route documents replay safety.

Show why and fix in the operator's error view to explain the failure and the suggested recovery. Keep the error identifier available for support and automation.

Other errors

One 202 throws instead of resolving with a handle. When the platform holds a write for a second approver, the 202 body carries status of pending-approval, an approvalId and a statusUrl, and the SDK throws a DalpApiError with the code sdk.transport.APPROVAL_HELD and retryable false. Do not repeat that write: the approval is the next step, and error.data.approvalId and error.data.statusUrl tell you which one to follow.

DalpSdkError reports a failure inside the SDK rather than an answer from the platform. Constructing a browser client with an API key throws one, and so does a scalar value the SDK cannot decode. Read its category, why and fix fields.

waitForTransaction, from the wait-for-transaction subpath, throws DalpTransactionWaitError. Its reason is terminal-failure, timeout, aborted, funding-paused or indexer-rebuild. By default the helper allows 120 seconds without progress and polls every 1000 milliseconds. It restarts that budget while it holds through a funding pause or an indexer rebuild.

Application responsibilities

Validate input before sending a write so the user can correct a form without waiting for an API response.

Refresh the affected reads after settlement. With TanStack Query, invalidate the query keys described in React Query.

Next

Routes that stream a sequence of events: Streaming routes.

Was this page helpful?