This guide describes how a lender frontend and backend integrate with Vault’s borrower-facing asset transfer UI. Vault supports two delivery modes: an embedded iframe that renders inside the lender app, and a hosted redirect that navigates the borrower to a Vault-owned page and back. Vault determines which delivery mode to use — the lender does not specify it in the launch request. The response includes a delivery_mode field so the lender knows which mode was selected.

Delivery modes

ModeWhat lender rendersWhat Vault returnsDescription
embeddedAn <iframe> pointing at embed_urlembed_url, expires_at, token, asset_transfer_instruction_idThe borrower stays inside the lender’s chrome. The lender listens to postMessage lifecycle events from the iframe.
hostedA full-page redirect to redirect_urlredirect_url, expires_at, token, asset_transfer_instruction_idThe borrower is redirected to a Vault-branded page and returned to the lender via a return_url.
Both modes share:
  • The same POST /api/v1/loans/{loan_ref}/transfer-launch endpoint.
  • The same Idempotency-Key and bearer-token contract.
  • The same underlying provider transfer instruction (asset_transfer_instruction_id is the canonical correlation key in both modes).
Vault resolves the delivery mode internally (currently via server configuration; per-lender configuration is planned). The response’s delivery_mode field tells the lender which mode was selected so the frontend can render accordingly.

Architecture overview

  1. Borrower is authenticated in lender UI.
  2. Lender backend calls POST /api/v1/loans/{loan_ref}/transfer-launch.
  3. Vault resolves the delivery mode, loan/asset/custody context, creates a mode-specific session, and returns either embed_url (embedded) or redirect_url (hosted), plus a short-lived token and the asset_transfer_instruction_id.
  4. Lender reads delivery_mode from the response and renders accordingly:
    • Embedded: mounts an iframe pointed at embed_url and listens for vault.asset_transfer.* events via postMessage.
    • Hosted: navigates the borrower to redirect_url. Vault’s hosted page runs the provider flow and redirects the borrower back to return_url with ?status=...&transfer_id=... query parameters.

Prerequisites

  • Vault API reachable from lender backend (for example http://localhost:8000 in dev).
  • Vault UI reachable from borrower browser (for example http://localhost:5173 in dev).
  • Lender backend has a Vault bearer token with scopes collateral:write and loans:read.
  • Lender frontend origin (for example http://localhost:3000) should be sent as the Origin header on the launch call (required when Vault resolves to embedded mode).
  • Lender provides a return_url in the request body (required when Vault resolves to hosted mode).

Backend launch call

Both modes use the same endpoint:
  • POST /api/v1/loans/{loan_ref}/transfer-launch
  • Required scopes: collateral:write and loans:read
  • Required headers:
    • Authorization: Bearer <token>
    • Idempotency-Key: stable client-generated key for one launch action.
    • Origin: required when Vault resolves to embedded mode (for parent-frame pinning). Safe to send unconditionally.
  • Path parameters:
    • loan_ref (required string).
  • Request body fields:
    • asset_ref (string, required)
    • quantity (number or numeric string, required)
    • custody_provider_code (string, required)
    • return_url (string, optional). The lender URL the borrower returns to after success/cancel/error. Required when Vault resolves to hosted mode; ignored for embedded mode.
    • lender_display_name (string, optional, max 120 chars). Brand string rendered to the borrower in the Vault-hosted page header. Used only in hosted mode. When omitted, Vault falls back to the calling lender party’s legal_name from entity_registry.
    • lender_logo_url (string, optional, max 2048 chars). URL of the lender’s logo, rendered next to the lender name in the hosted page header. Used only in hosted mode. Must be https in production; http is permitted only when the launch request itself is made over http (covers local dev). data: and other non-http(s) schemes are rejected with 422. There is no fallback for the logo — when omitted, the page renders only the lender name.
Since lenders may not know in advance which delivery mode Vault will select, it is safe to include return_url, Origin, and branding fields on every request. Vault uses only the fields relevant to the resolved mode and ignores the rest.

Response shape

A successful launch always returns:
  • status ("ok" on success)
  • delivery_mode (echoes the resolved mode)
  • expires_at
  • token (short-lived, single-use)
  • asset_transfer_instruction_id
Plus exactly one of:
  • embed_url (when delivery_mode = "embedded") — the URL to mount in the iframe; the token is in the path and parent_origin is appended as a query param.
  • redirect_url (when delivery_mode = "hosted") — the URL to navigate the browser to; the token is in the path.
The other URL field is null. Lender code should treat the launch artifacts as conditional on status == "ok" and on the expected URL field being present, and should fail closed otherwise.

Validation rules

ConditionHTTP statusNotes
Idempotency-Key header missing422Required for all launches.
Vault resolves to embedded + Origin header missing422Embedded launches must pin the parent origin.
Vault resolves to hosted + return_url missing/empty422Hosted launches must provide a borrower-return URL.
lender_logo_url not http(s) (e.g. data:, javascript:)422Only http(s) URLs are accepted.
lender_logo_url is http://... and the launch request is https://...422http URLs only allowed when the launch request itself is on http.
Since lenders do not control the delivery mode, it is recommended to always send the Origin header and include a return_url in the body, so the request succeeds regardless of which mode Vault selects.

Example backend route (Node/Express)

This example sends the core transfer fields plus return_url and Origin unconditionally, then reads delivery_mode from the Vault response to decide what to return to the frontend:
import express from "express";
import { randomUUID } from "crypto";

const app = express();
app.use(express.json());

const VAULT_API_BASE_URL = process.env.VAULT_API_BASE_URL || "http://localhost:8000";
const VAULT_BEARER_TOKEN = process.env.VAULT_BEARER_TOKEN || "";
const LENDER_UI_ORIGIN = process.env.LENDER_UI_ORIGIN || "http://localhost:3000";
const LENDER_RETURN_URL = process.env.LENDER_RETURN_URL || "http://localhost:3000/transfer-return";

app.post("/api/vault/loan-transfer-launch", async (req, res) => {
  try {
    const { loanRef, assetRef, quantity, custodyProviderCode } = req.body;
    if (!loanRef || !assetRef || !quantity || !custodyProviderCode) {
      return res.status(400).json({
        error: "loanRef, assetRef, quantity, custodyProviderCode are required",
      });
    }

    const idempotencyKey = req.get("X-Request-Id") || randomUUID();
    const headers = {
      Authorization: `Bearer ${VAULT_BEARER_TOKEN}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
      Origin: LENDER_UI_ORIGIN,
    };

    const body = {
      asset_ref: assetRef,
      quantity,
      custody_provider_code: custodyProviderCode,
      return_url: `${LENDER_RETURN_URL}/${encodeURIComponent(loanRef)}`,
    };
    if (req.body.lenderDisplayName) {
      body.lender_display_name = req.body.lenderDisplayName;
    }
    if (req.body.lenderLogoUrl) {
      body.lender_logo_url = req.body.lenderLogoUrl;
    }

    const vaultResp = await fetch(
      `${VAULT_API_BASE_URL}/api/v1/loans/${encodeURIComponent(loanRef)}/transfer-launch`,
      { method: "POST", headers, body: JSON.stringify(body) },
    );

    const payload = await vaultResp.json();
    if (!vaultResp.ok) {
      return res.status(vaultResp.status || 502).json({
        error: "Failed to create Vault transfer launch",
        detail: payload,
      });
    }

    return res.json({
      deliveryMode: payload.delivery_mode,
      embedUrl: payload.embed_url,
      redirectUrl: payload.redirect_url,
      expiresAt: payload.expires_at,
      token: payload.token,
      assetTransferInstructionId: payload.asset_transfer_instruction_id,
    });
  } catch (error) {
    return res.status(502).json({
      error: "Vault unavailable",
      detail: error instanceof Error ? error.message : String(error),
    });
  }
});

Embedded mode — frontend iframe + event listener

Render embedUrl in an iframe and consume only the documented contract events from Vault:
  • vault.asset_transfer.ready
  • vault.asset_transfer.completed
  • vault.asset_transfer.error
  • vault.asset_transfer.closed

Example frontend component (React)

import { useEffect, useMemo, useState } from "react";

const VAULT_UI_ORIGIN = "http://localhost:5173";

export default function VaultEmbeddedTransferPage() {
  const [embedUrl, setEmbedUrl] = useState("");
  const [events, setEvents] = useState([]);
  const loanRef = "LOAN-EXT-123";

  async function start() {
    const resp = await fetch("/api/vault/loan-transfer-launch", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        loanRef,
        assetRef: "BTC",
        quantity: 0.001,
        custodyProviderCode: "ANCHORAGE",
      }),
    });
    const payload = await resp.json();
    if (!resp.ok) {
      throw new Error(payload?.detail?.detail || payload?.error || "Launch failed");
    }
    if (payload.deliveryMode === "hosted" && payload.redirectUrl) {
      window.location.href = payload.redirectUrl;
      return;
    }
    setEmbedUrl(payload.embedUrl);
    console.log("assetTransferInstructionId:", payload.assetTransferInstructionId);
  }

  useEffect(() => {
    function onMessage(event) {
      if (event.origin !== VAULT_UI_ORIGIN) return;
      const data = event.data || {};
      if (!String(data.type || "").startsWith("vault.asset_transfer.")) return;
      setEvents((prev) => [...prev, data]);
    }
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

  const iframeSrc = useMemo(() => embedUrl, [embedUrl]);

  return (
    <div>
      <button onClick={start}>Start Vault Transfer</button>
      {iframeSrc ? (
        <iframe
          title="Vault Transfer"
          src={iframeSrc}
          style={{ width: "100%", height: "760px", border: "1px solid #ddd" }}
          allow="clipboard-read; clipboard-write"
        />
      ) : null}
      <pre>{JSON.stringify(events, null, 2)}</pre>
    </div>
  );
}

Embedded event contract

Vault posts messages to the parent window using the parent_origin value baked into embed_url.
  • vault.asset_transfer.ready — payload: none.
  • vault.asset_transfer.completed — payload: { status } with values pending, succeeded, failed, success (alias of succeeded), failure (alias of failed), or unknown.
  • vault.asset_transfer.error — payload: { message }.
  • vault.asset_transfer.closed — payload: none.
Listener requirements:
  • Validate event.origin against the Vault UI origin.
  • Consume only documented contract event types and ignore anything else.
  • Keep the iframe mounted until vault.asset_transfer.closed.
  • Use vault.asset_transfer.completed to capture the transfer outcome — do not advance lender workflow on closed alone.
  • If closed arrives before completed, treat the launch as cancelled or abandoned.

Hosted mode — full-page redirect + return URL

Hosted mode replaces the iframe + postMessage contract with a full-page navigation to a Vault-owned page and a redirect back to the lender’s return_url when the flow ends.

Lifecycle

  1. Lender backend calls transfer-launch with a return_url in the body. Vault resolves to hosted mode.
  2. Lender frontend (or backend) navigates the borrower to the redirect_url returned in the response (a /asset_transfer/hosted/{token} URL on Vault).
  3. Vault’s hosted page resolves the token, runs the provider transfer flow, and on terminal events redirects the borrower’s browser to:
    {return_url}?status=<completed|cancelled|error>&transfer_id=<asset_transfer_instruction_id>
    
  4. Lender’s return-URL handler reads status and transfer_id from the query string and finishes the borrower’s lender-side flow.

Status enum on the return URL

statusMeaning
completedProvider reported a terminal success (success exit or transferFinished callback observed).
cancelledBorrower closed the Vault page without a terminal success and without a provider error.
errorProvider reported a terminal error or Vault could not run the flow to completion.
The transfer_id query param is always Vault’s asset_transfer_instruction_id for the launch — use it as the canonical correlation key for any follow-up reconciliation.

Example: lender backend kicks off a hosted launch and 302s the borrower

app.post("/lender/loans/:loanRef/transfer-start", async (req, res) => {
  const { loanRef } = req.params;
  const launch = await fetch("/api/vault/loan-transfer-launch", {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: req.get("cookie") || "" },
    body: JSON.stringify({
      loanRef,
      assetRef: "BTC",
      quantity: 0.001,
      custodyProviderCode: "ANCHORAGE",
    }),
  }).then((r) => r.json());

  if (launch.deliveryMode === "hosted" && launch.redirectUrl) {
    return res.redirect(302, launch.redirectUrl);
  }
  if (launch.deliveryMode === "embedded" && launch.embedUrl) {
    return res.json({ embedUrl: launch.embedUrl });
  }
  return res.status(502).json({ error: "Unexpected Vault response", detail: launch });
});

Example: lender return-URL handler

app.get("/loans/:loanRef/transfer-return", (req, res) => {
  const { status, transfer_id: transferId } = req.query;
  if (!status || !transferId) {
    return res.status(400).send("Missing status or transfer_id");
  }
  // Persist outcome keyed on transferId (Vault's asset_transfer_instruction_id)
  // and surface the right next screen to the borrower.
  return res.render("transfer-return", { status, transferId, loanRef: req.params.loanRef });
});

Hosted-mode requirements

  • return_url should be HTTPS in non-dev environments and should resolve to a lender-owned host.
  • Do not assume the borrower returns synchronously — always reconcile by transfer_id if the user closes the tab before redirect.
  • Treat ?status= strictly as one of the documented values; reject unknown statuses defensively.
  • Do not parse provider-specific data from the query string; Vault deliberately exposes only status and transfer_id.

Hosted-page branding

The Vault-hosted page renders a header band with the Vault wordmark on the left and the lender’s brand on the right. The lender brand has two parts:
  • Lender display name. Used as the right-side label in the header and substituted into the borrower-facing copy (“You’ll be returned to when finished” and the error helpers). When the lender does not supply lender_display_name, Vault looks up the calling lender party’s legal_name from entity_registry and uses that. If neither source is available the page falls back to the generic “your lender” copy and shows just the Vault wordmark in the header.
  • Lender logo. Rendered as a small image to the left of the display name when lender_logo_url is supplied. There is no fallback. The image is loaded with referrer-policy: no-referrer; if the URL fails to load the image is hidden silently and the display name still renders.
Branding is hosted-only. The embedded flow renders inside the lender’s own chrome, so the lender already controls all of its branding there. If branding fields are included and Vault resolves to embedded mode, they are silently ignored.

Understanding the delivery modes

Vault selects the delivery mode. The lender frontend should handle both modes by reading the delivery_mode field from the launch response and rendering accordingly:
  • embedded (embed_url present): mount an iframe and wire up the postMessage listener. Best when the lender app can host an iframe and you control its CSP / frame-ancestors.
  • hosted (redirect_url present): navigate the borrower to the redirect URL. Best for mobile webviews, third-party shells, or environments where iframes are restricted.

Origin and security requirements

  • Never expose the Vault bearer token to the browser.
  • For embedded mode, the Origin header on the launch request must exactly match the lender UI origin (scheme + host + port). Avoid mixing localhost and 127.0.0.1 — they are distinct origins.
  • For embedded mode, validate event.origin against the Vault UI origin in the frontend listener.
  • For hosted mode, treat return_url as part of your security boundary: only register URLs you control, and validate ?status and ?transfer_id defensively before acting on them.
  • Treat all session tokens (token returned from the launch) as short-lived and single-use. Re-launch on any retry; never reuse a stale embed_url or redirect_url.

Idempotency and duplicate-launch prevention

The launch endpoint is idempotent on the Idempotency-Key header per (party, loan_ref). The fingerprint covers the caller-supplied request fields (asset_ref, quantity, custody_provider_code, return_url, etc.), so reusing a key with different request fields returns 409 — generate a fresh key when the borrower starts a new launch action. Lender-side controls that apply to both modes:
  • Single-flight guard per idempotency key in the lender backend.
  • Disable the launch UI while the call is in flight.
  • Carry X-Request-Id through logs for cross-system traceability.
Mode-specific:
  • Embedded: render only one iframe per embed_url; do not auto-mount on every framework lifecycle hook.
  • Hosted: issue the redirect once per launch; if the borrower hits the back button, re-launch (do not reuse the prior redirect_url).

Observability and dashboards

All Vault asset transfer logs and metrics are tagged with delivery_mode so embedded vs hosted volumes and error rates can be split:
  • Logs. Vault logs the resolved delivery_mode on launch (transfer_launch requested ... delivery_mode=...), session creation, event ingestion, and terminal events. Filter or facet on delivery_mode when investigating a launch.
  • Metrics / dashboards. Recommend a dashboard panel split per delivery_mode for at minimum:
    • launch success vs failure rate,
    • terminal outcome distribution (completed / cancelled / error for hosted; completed status / error / closed-without-completed for embedded),
    • p50/p95 time-to-terminal-event,
    • 4xx vs 5xx rate on the launch endpoint.
  • Alerting. Tune alert thresholds per mode — embedded and hosted have different baseline error/abandon rates and should not share a single SLO.
  • Reconciliation. Always reconcile by asset_transfer_instruction_id; the same instruction is referenced by both embed_url/redirect_url payloads and downstream provider events.

Local testing checklist

  1. Start Vault API and Vault UI, plus the lender backend and frontend.
  2. Embedded: set DEFAULT_ASSET_TRANSFER_DELIVERY_MODE=embedded on the Vault API, launch a transfer, confirm the response has delivery_mode = "embedded" and the iframe loads /asset_transfer/embed/{token}, and observe the readycompletedclosed event sequence.
  3. Hosted: set DEFAULT_ASSET_TRANSFER_DELIVERY_MODE=hosted on the Vault API, launch with a return_url, confirm the response has delivery_mode = "hosted" and the borrower lands on /asset_transfer/hosted/{token}, runs the flow, and is redirected back to {return_url}?status=...&transfer_id=....
  4. Confirm one-time token behavior: re-loading the embed/hosted URL should not re-run the flow.
  5. Confirm idempotency: replaying the launch with the same Idempotency-Key and identical body returns the original launch artifacts; with a different body it returns 409.
  6. Confirm the lender frontend handles both modes by reading delivery_mode from the response.

Demo environment variables

Backend:
  • VAULT_API_BASE_URL=http://localhost:8000
  • VAULT_BEARER_TOKEN=<service-token-with-collateral-write-and-loans-read>
  • LENDER_UI_ORIGIN=http://localhost:3000
Frontend:
  • VAULT_UI_ORIGIN=http://localhost:5173

Production hardening

  • Add correlation IDs from the lender request through Vault APIs for traceability.
  • Add bounded retry/backoff only for transient session-bootstrap network failures, not for provider-side failures.
  • Provide a graceful fallback UX if launch fails (for example, fall back to hosted if embedded refuses to load behind a strict CSP).
  • Instrument completion/error metrics by lender tenant and integration partner, split by delivery_mode.