This guide describes how a lender frontend and backend integrate with Vault’s hosted asset transfer widget.

Integration goal

Build a lender flow where:
  1. The borrower is authenticated in lender UI.
  2. Lender backend calls Vault transfer launch endpoint.
  3. Lender frontend renders Vault embed URL in an iframe.
  4. Lender frontend listens to Vault lifecycle events via postMessage.

Architecture overview

  1. Lender backend calls POST /api/v1/loans/{loan_ref}/transfer-launch.
  2. Vault returns launch artifacts: embed_url, expires_at, token, asset_transfer_instruction_id.
  3. Lender frontend loads embed_url in an iframe.
  4. Vault iframe resolves token, starts provider transfer session, and emits lifecycle events to parent window.

Prerequisites

  • Vault API reachable from lender backend (for example http://34.170.194.254:8000)
  • Vault UI reachable from borrower browser (for example http://34.170.194.254:5173 in dev)
  • Lender backend has a valid Vault bearer token with scopes:
    • collateral:write
    • loans:read
  • Lender frontend origin is known (for example http://34.170.194.254:3000) and sent to Vault using Origin header

Backend launch call

Use Vault endpoint:
  • POST /api/v1/loans/{loan_ref}/transfer-launch
  • Required scopes: collateral:write and loans:read
  • Required headers:
    • Origin: lender UI origin (exact scheme + host + port)
    • Idempotency-Key: stable key for one launch action
  • Path params:
    • loan_ref (required string)
  • Required body fields:
    • asset_ref
    • quantity (number or string)
    • custody_provider_code
  • Expected response artifacts:
    • status
    • embed_url (includes token + parent origin context)
    • expires_at
    • token
    • asset_transfer_instruction_id
Treat embed_url, expires_at, and token as launch artifacts that may be absent on non-ok responses. Lender code should fail when embed_url is missing. The lender frontend should not call Vault transfer session APIs directly. It should only consume embed_url returned by lender backend. Use asset_transfer_instruction_id as the correlation key for downstream lender/Vault workflows.

Example backend route (Node/Express)

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";

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 vaultResp = await fetch(
      `${VAULT_API_BASE_URL}/api/v1/loans/${encodeURIComponent(loanRef)}/transfer-launch`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${VAULT_BEARER_TOKEN}`,
          "Content-Type": "application/json",
          Origin: LENDER_UI_ORIGIN,
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({
          asset_ref: assetRef,
          quantity,
          custody_provider_code: custodyProviderCode,
        }),
      }
    );

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

    return res.json({
      embedUrl: payload.embed_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),
    });
  }
});

Frontend iframe + event listener

Render embedUrl in an iframe and consume only 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 VaultTransferPage() {
  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 || "Embed session failed");
    }

    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>
  );
}

Event contract from Vault hosted widget

Vault posts messages to parent window using embed-session parent origin context.
  • vault.asset_transfer.ready
    • payload: none
  • vault.asset_transfer.completed
    • payload: { status }
    • status values:
      • pending
      • succeeded
      • failed
      • success (alias of succeeded)
      • failure (alias of failed)
      • unknown
  • vault.asset_transfer.error
    • payload: { message }
  • vault.asset_transfer.closed
    • payload: none
Lender code should rely on event type as the stable contract and not parse provider-specific raw event streams.

Listener behavior requirements

  • Validate event.origin against Vault UI origin (for example http://34.170.194.254:5173)
  • Consume only documented contract event types
  • Ignore unexpected/non-contract events
  • Keep iframe mounted until vault.asset_transfer.closed
  • Use vault.asset_transfer.completed to capture transfer outcome
  • Do not advance lender workflow based on closed alone
  • If closed arrives before completed, treat launch as cancelled/abandoned

Origin alignment checklist

To avoid origin-validation failures, keep these values aligned exactly (scheme + host + port):
  • Lender backend Origin header on launch call must equal lender browser origin
  • embed_url-derived parent origin should match lender frontend origin exactly
  • Lender frontend validates event.origin against Vault UI origin
  • Avoid mixing localhost and 127.0.0.1 (different origins)

Origin and security requirements

  • Never expose Vault bearer token in browser code.
  • Origin header on launch request must exactly match lender UI origin.
  • Validate event.origin against Vault UI origin in frontend listener.
  • Treat embed tokens as short-lived and one-time use.
  • Re-launch for retries; never reuse stale embed_url tokens.

Retry and lifecycle guidance

vault.asset_transfer.error generally appears in two categories:
  • bootstrap/launch failures before provider flow is active (token/session/setup issues)
  • provider-side failures before a completed outcome is observed
Retry guidance:This page is intended to replace the temporary integration markdown and serve as the implementation guide.

Correlation key

Use asset_transfer_instruction_id as the identifier for reconciliation across lender and Vault workflows.

Duplicate launch prevention

Vault embed tokens are single-use. Duplicate launch calls or duplicate iframe mounts can cause noisy logs and one-time token failures. Recommended lender-side controls:
  • implement single-flight guard per idempotency key
  • disable launch UI while launch call is in-flight
  • ensure framework lifecycle hooks cannot auto-launch multiple times
  • render only one iframe per embedUrl
  • never reuse old embed token/URL; request fresh launch on retry
  • carry X-Request-Id through logs for cross-system traceability
Idempotency key rules:
  • one stable key per user launch action
  • reuse only for retry of same action/payload
  • generate a new key for each new user-initiated launch

Local testing checklist

  1. Start Vault API/UI and lender backend/frontend.
  2. Launch transfer from lender UI.
  3. Confirm iframe loads and emits expected event sequence.
  4. Confirm Vault runtime endpoints are invoked after iframe load.
  5. Confirm one-time token behavior and idempotent retry behavior.
  6. Confirm mismatched Origin is rejected.

Demo environment variables

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

Production hardening

  • Add correlation IDs from lender request through Vault APIs for traceability
  • Add retry/backoff only for session bootstrap network failures
  • Provide graceful fallback UX if embed launch fails
  • Instrument completion/error metrics by lender tenant and integration partner