> ## Documentation Index
> Fetch the complete documentation index at: https://docs.etherfuse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded Wallets Sandbox Playground

> Sandbox interactive tester and minimal server-side onramp script for embedded wallets

export const EmbeddedWalletTutorial = ({sandboxOnly = true} = {}) => {
  const {useState, useEffect, useCallback, useRef} = React;
  const _palette = {
    border: '#e5e7eb',
    panel: '#f8fafc',
    panelAlt: '#f9fafb',
    blue: '#3b82f6',
    greenBg: '#dcfce7',
    greenText: '#166534',
    greenDot: '#22c55e',
    redText: '#dc2626',
    redBg: '#fef2f2',
    muted: '#6b7280',
    mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
  };
  const _stepBadgeColor = status => {
    if (status === 'done') return {
      bg: '#dcfce7',
      color: '#166534'
    };
    if (status === 'active') return {
      bg: '#dbeafe',
      color: '#1e40af'
    };
    if (status === 'error') return {
      bg: '#fef2f2',
      color: '#dc2626'
    };
    return {
      bg: '#f3f4f6',
      color: '#6b7280'
    };
  };
  const _stepDotStyle = status => {
    const base = {
      width: 24,
      height: 24,
      borderRadius: '50%',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      fontSize: 12,
      fontWeight: 600,
      flexShrink: 0
    };
    if (status === 'done') return {
      ...base,
      backgroundColor: '#22c55e',
      color: '#fff'
    };
    if (status === 'active') return {
      ...base,
      backgroundColor: _palette.blue,
      color: '#fff'
    };
    if (status === 'error') return {
      ...base,
      backgroundColor: _palette.redText,
      color: '#fff'
    };
    return {
      ...base,
      backgroundColor: '#e5e7eb',
      color: _palette.muted
    };
  };
  const _codeStyle = {
    width: '100%',
    maxHeight: 260,
    overflow: 'auto',
    padding: 12,
    fontFamily: _palette.mono,
    fontSize: 12,
    lineHeight: 1.5,
    borderRadius: 6,
    backgroundColor: _palette.panel,
    color: '#334155',
    border: '1px solid #e2e8f0',
    margin: 0,
    whiteSpace: 'pre-wrap',
    wordBreak: 'break-word',
    boxSizing: 'border-box'
  };
  const _btnSecondary = {
    padding: '6px 12px',
    borderRadius: 6,
    border: '1px solid #d1d5db',
    cursor: 'pointer',
    backgroundColor: 'transparent',
    color: _palette.muted,
    fontSize: 12
  };
  const _btnPrimary = (enabled, busy) => ({
    padding: '8px 16px',
    borderRadius: 6,
    border: 'none',
    cursor: enabled ? 'pointer' : 'not-allowed',
    backgroundColor: enabled ? _palette.blue : '#9ca3af',
    color: '#fff',
    fontSize: 13,
    fontWeight: 500,
    opacity: busy ? 0.7 : 1
  });
  const StepCard = ({name, num, title, subtitle, children, action, actionLabel, actionDisabled, showRefresh, onRefresh, steps, busy}) => {
    const status = steps[name];
    const colors = _stepBadgeColor(status);
    return <div style={{
      marginBottom: 16,
      border: `1px solid ${_palette.border}`,
      borderRadius: 8,
      overflow: 'hidden',
      opacity: status === 'idle' ? 0.6 : 1
    }}>
        <div style={{
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
      padding: '10px 14px',
      backgroundColor: _palette.panel,
      borderBottom: `1px solid ${_palette.border}`
    }}>
          <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 8
    }}>
            <span style={_stepDotStyle(status)}>{status === 'done' ? '✓' : num}</span>
            <span style={{
      fontSize: 14,
      fontWeight: 500
    }}>{title}</span>
            {subtitle && <span style={{
      fontSize: 12,
      color: _palette.muted
    }}>{subtitle}</span>}
            {status !== 'idle' && <span style={{
      fontSize: 11,
      padding: '2px 8px',
      borderRadius: 10,
      backgroundColor: colors.bg,
      color: colors.color,
      fontWeight: 500
    }}>
                {status}
              </span>}
          </div>
          <div style={{
      display: 'flex',
      gap: 6
    }}>
            {showRefresh && <button onClick={onRefresh} style={_btnSecondary} title="Re-read order now">Refresh</button>}
            {action && <button onClick={action} disabled={actionDisabled || busy} style={_btnPrimary(!actionDisabled && !busy, busy)}>
                {actionLabel || 'Run'}
              </button>}
          </div>
        </div>
        <div style={{
      padding: 14
    }}>{children}</div>
      </div>;
  };
  const LogPanel = ({stepName, log}) => {
    const entry = log[stepName];
    if (!entry) return null;
    return <div style={{
      marginTop: 10
    }}>
        {entry.req !== null && entry.req !== undefined && <div style={{
      marginBottom: 6
    }}>
            <div style={{
      fontSize: 11,
      color: _palette.muted,
      marginBottom: 3
    }}>Request</div>
            <pre style={_codeStyle}>{typeof entry.req === 'string' ? entry.req : JSON.stringify(entry.req, null, 2)}</pre>
          </div>}
        {entry.res && <div style={{
      marginBottom: 6
    }}>
            <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 6,
      marginBottom: 3
    }}>
              <span style={{
      fontSize: 11,
      padding: '2px 6px',
      borderRadius: 4,
      backgroundColor: _palette.greenBg,
      color: _palette.greenText,
      fontWeight: 500
    }}>200</span>
              <span style={{
      fontSize: 11,
      color: _palette.muted
    }}>Response</span>
            </div>
            <pre style={_codeStyle}>{JSON.stringify(entry.res, null, 2)}</pre>
          </div>}
        {entry.error && <div style={{
      padding: 10,
      backgroundColor: _palette.redBg,
      color: _palette.redText,
      borderRadius: 6,
      fontSize: 13,
      border: '1px solid #fecaca'
    }}>
            <strong>Error:</strong> {entry.error}
          </div>}
      </div>;
  };
  const [envSandbox, setEnvSandbox] = useState(true);
  const isSandbox = sandboxOnly || envSandbox;
  const [apiKey, setApiKey] = useState('');
  const isProdKey = apiKey.startsWith('api_prod:');
  const prodKeyBlocked = sandboxOnly && isProdKey;
  useEffect(() => {
    const stored = sessionStorage.getItem('etherfuse_api_key');
    if (stored) setApiKey(stored);
  }, []);
  const handleApiKeyChange = value => {
    setApiKey(value);
    if (value) {
      sessionStorage.setItem('etherfuse_api_key', value);
    } else {
      sessionStorage.removeItem('etherfuse_api_key');
    }
  };
  const baseUrl = isSandbox ? 'https://api.sand.etherfuse.com' : 'https://api.etherfuse.com';
  const rejectProdKey = () => {
    if (prodKeyBlocked) {
      throw new Error('Production API keys are not allowed in this sandbox playground. Use a sandbox key (api_sand:...).');
    }
  };
  const getOrgId = () => {
    if (!apiKey) return null;
    const parts = apiKey.split(':');
    if (parts.length >= 3) {
      const id = parts[2];
      if ((/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).test(id)) return id;
    }
    return null;
  };
  const orgId = getOrgId();
  const [direction, setDirection] = useState('onramp');
  const [fiatAmount, setFiatAmount] = useState('100');
  const [fiatCurrency, setFiatCurrency] = useState('MXN');
  const [targetAsset, setTargetAsset] = useState('');
  const [assets, setAssets] = useState([]);
  const [assetsError, setAssetsError] = useState('');
  const [bankAccounts, setBankAccounts] = useState([]);
  const [selectedBankAccountId, setSelectedBankAccountId] = useState('');
  const [signerPublicKey, setSignerPublicKey] = useState('');
  const signerJwkRef = useRef(null);
  const [privateKeyPem, setPrivateKeyPem] = useState('');
  const [showPrivateKey, setShowPrivateKey] = useState(false);
  const signKeyRef = useRef(null);
  const [walletId, setWalletId] = useState('');
  const [walletAddress, setWalletAddress] = useState('');
  const [customerId, setCustomerId] = useState('');
  const [quoteId, setQuoteId] = useState('');
  const [orderId, setOrderId] = useState('');
  const [quoteResponse, setQuoteResponse] = useState(null);
  const [orderResponse, setOrderResponse] = useState(null);
  const [approval, setApproval] = useState(null);
  const [approvalSubmitted, setApprovalSubmitted] = useState(false);
  const [isClaimableApproval, setIsClaimableApproval] = useState(false);
  const [finalOrder, setFinalOrder] = useState(null);
  const [steps, setSteps] = useState({
    keygen: 'active',
    provision: 'idle',
    quote: 'idle',
    order: 'idle',
    fiat: 'idle',
    watch: 'idle',
    sign: 'idle',
    result: 'idle'
  });
  const setStep = (name, status) => setSteps(prev => ({
    ...prev,
    [name]: status
  }));
  const [log, setLog] = useState({});
  const setStepLog = (name, entry) => setLog(prev => ({
    ...prev,
    [name]: entry
  }));
  const [busy, setBusy] = useState(false);
  const pollingRef = useRef(false);
  const pollRunRef = useRef(0);
  const uuid = () => ('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx').replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0;
    return (c === 'x' ? r : r & 0x3 | 0x8).toString(16);
  });
  useEffect(() => {
    setQuoteId(uuid());
    setOrderId(uuid());
  }, []);
  useEffect(() => {
    if (!apiKey || !walletAddress) {
      setAssets([]);
      setAssetsError('');
      return;
    }
    const load = async () => {
      if (prodKeyBlocked) return;
      setAssetsError('');
      try {
        const params = new URLSearchParams({
          blockchain: 'stellar',
          currency: fiatCurrency.toLowerCase(),
          wallet: walletAddress
        });
        const res = await fetch(`${baseUrl}/ramp/assets?${params}`, {
          headers: {
            Authorization: apiKey
          }
        });
        if (!res.ok) {
          setAssets([]);
          setAssetsError(`GET /ramp/assets returned ${res.status}`);
          return;
        }
        const data = await res.json();
        const list = (data.assets || []).map(a => ({
          tokenIdentifier: a.identifier,
          fxRampCurrency: (a.currency || fiatCurrency).toUpperCase(),
          bondSymbol: a.symbol || ''
        }));
        setAssets(list);
        const mxn = list.find(a => (a.fxRampCurrency || '').toUpperCase() === 'MXN');
        if (mxn) {
          setTargetAsset(mxn.tokenIdentifier);
          setFiatCurrency('MXN');
        } else if (list.length > 0) {
          setTargetAsset(list[0].tokenIdentifier);
          if (list[0].fxRampCurrency) setFiatCurrency(list[0].fxRampCurrency);
        }
        if (list.length === 0) setAssetsError('GET /ramp/assets returned no Stellar assets');
      } catch (e) {
        setAssets([]);
        setAssetsError(e.message || 'Failed to load assets from GET /ramp/assets');
      }
    };
    load();
  }, [baseUrl, apiKey, walletAddress, fiatCurrency, prodKeyBlocked]);
  useEffect(() => {
    if (!apiKey || !orgId || prodKeyBlocked) return;
    const load = async () => {
      try {
        const res = await fetch(baseUrl + `/ramp/customer/${orgId}/bank-accounts`, {
          method: 'POST',
          headers: {
            'Authorization': apiKey,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            pageNumber: 0,
            pageSize: 25
          })
        });
        if (!res.ok) return;
        const data = await res.json();
        const items = data.items || [];
        setBankAccounts(items);
        const match = items.find(a => (a.currency || '').toUpperCase() === fiatCurrency.toUpperCase());
        if (match) setSelectedBankAccountId(match.bankAccountId); else if (items.length > 0) setSelectedBankAccountId(items[0].bankAccountId);
      } catch {}
    };
    load();
  }, [apiKey, orgId, baseUrl, prodKeyBlocked, fiatCurrency]);
  useEffect(() => {
    if (!apiKey || prodKeyBlocked) {
      setCustomerId('');
      return;
    }
    const load = async () => {
      try {
        const res = await fetch(`${baseUrl}/ramp/me`, {
          headers: {
            Authorization: apiKey
          }
        });
        if (!res.ok) return;
        const data = await res.json();
        if (data.id) setCustomerId(data.id);
      } catch {}
    };
    load();
  }, [apiKey, baseUrl, prodKeyBlocked]);
  useEffect(() => {
    const a = assets.find(x => x.tokenIdentifier === targetAsset);
    if (a) setFiatCurrency(a.fxRampCurrency.toUpperCase());
  }, [targetAsset, assets]);
  useEffect(() => {
    if (bankAccounts.length === 0) return;
    const match = bankAccounts.find(a => (a.currency || '').toUpperCase() === fiatCurrency.toUpperCase());
    if (match) setSelectedBankAccountId(match.bankAccountId);
  }, [fiatCurrency, bankAccounts]);
  const hexEncode = bytes => [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
  const b64urlDecode = s => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
  function compressedPubFromJwk(jwk) {
    const x = b64urlDecode(jwk.x), y = b64urlDecode(jwk.y);
    const c = new Uint8Array(33);
    c[0] = y[y.length - 1] % 2 === 0 ? 0x02 : 0x03;
    c.set(x, 1);
    return hexEncode(c);
  }
  const pkcs8ToPem = buf => {
    const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
    return '-----BEGIN PRIVATE KEY-----\n' + b64.match(/.{1,64}/g).join('\n') + '\n-----END PRIVATE KEY-----';
  };
  async function generateKeyPair() {
    const kp = await crypto.subtle.generateKey({
      name: 'ECDSA',
      namedCurve: 'P-256'
    }, true, ['sign', 'verify']);
    const jwk = await crypto.subtle.exportKey('jwk', kp.publicKey);
    const pub = compressedPubFromJwk(jwk);
    const pkcs8 = await crypto.subtle.exportKey('pkcs8', kp.privateKey);
    return {
      privateKey: kp.privateKey,
      signerPublicKey: pub,
      signerPublicKeyJwk: jwk,
      privateKeyPem: pkcs8ToPem(pkcs8)
    };
  }
  async function signApproval(approvalMessage, signKey) {
    const sig = await crypto.subtle.sign({
      name: 'ECDSA',
      hash: 'SHA-256'
    }, signKey, new TextEncoder().encode(approvalMessage));
    return hexEncode(new Uint8Array(sig));
  }
  const apiFetch = useCallback(async (method, path, body) => {
    rejectProdKey();
    const opts = {
      method,
      headers: {
        'Authorization': apiKey,
        'Content-Type': 'application/json'
      }
    };
    if (body !== undefined) opts.body = JSON.stringify(body);
    const res = await fetch(baseUrl + path, opts);
    const text = await res.text();
    let json;
    try {
      json = text ? JSON.parse(text) : {};
    } catch {
      json = {
        raw: text
      };
    }
    if (!res.ok) throw Object.assign(new Error(`${res.status}: ${text.slice(0, 300)}`), {
      status: res.status,
      body: json
    });
    return json;
  }, [apiKey, baseUrl, prodKeyBlocked]);
  const resetDownstreamState = () => {
    setWalletId('');
    setWalletAddress('');
    setQuoteResponse(null);
    setOrderResponse(null);
    setApproval(null);
    setApprovalSubmitted(false);
    setIsClaimableApproval(false);
    setFinalOrder(null);
    setQuoteId(uuid());
    setOrderId(uuid());
    pollRunRef.current += 1;
    pollingRef.current = false;
    setSteps(prev => ({
      ...prev,
      provision: 'idle',
      quote: 'idle',
      order: 'idle',
      fiat: 'idle',
      watch: 'idle',
      sign: 'idle',
      result: 'idle'
    }));
  };
  const handleGenKey = async () => {
    setBusy(true);
    setStep('keygen', 'active');
    resetDownstreamState();
    try {
      const kp = await generateKeyPair();
      signKeyRef.current = kp.privateKey;
      signerJwkRef.current = kp.signerPublicKeyJwk;
      setSignerPublicKey(kp.signerPublicKey);
      setPrivateKeyPem(kp.privateKeyPem);
      setStep('keygen', 'done');
      setStep('provision', 'active');
    } catch (e) {
      setStepLog('keygen', {
        error: e.message
      });
      setStep('keygen', 'error');
    } finally {
      setBusy(false);
    }
  };
  const handleProvision = async () => {
    if (!signerJwkRef.current || !signKeyRef.current) {
      alert('Generate a keypair first.');
      return;
    }
    setBusy(true);
    setStep('provision', 'active');
    const reqBody = {
      walletId: uuid(),
      signer: {
        signerPublicKeyJwk: signerJwkRef.current
      }
    };
    try {
      const res = await apiFetch('POST', '/ramp/wallet', reqBody);
      setWalletId(res.walletId);
      setWalletAddress(res.publicKey);
      setStepLog('provision', {
        req: reqBody,
        res
      });
      setStep('provision', 'done');
      setStep('quote', 'active');
    } catch (e) {
      setStepLog('provision', {
        req: reqBody,
        error: e.message
      });
      setStep('provision', 'error');
    } finally {
      setBusy(false);
    }
  };
  const handleQuote = async () => {
    if (!walletAddress) {
      alert('Provision a wallet first.');
      return;
    }
    if (!customerId) {
      alert('Could not resolve customerId from GET /ramp/me. Check your API key.');
      return;
    }
    if (!targetAsset) {
      alert('Wait for Stellar assets to load (or pick one above) before quoting.');
      return;
    }
    setBusy(true);
    setStep('quote', 'active');
    const newQuoteId = uuid();
    setQuoteId(newQuoteId);
    const reqBody = {
      quoteId: newQuoteId,
      customerId,
      blockchain: 'stellar',
      quoteAssets: direction === 'onramp' ? {
        type: 'onramp',
        sourceAsset: fiatCurrency,
        targetAsset
      } : {
        type: 'offramp',
        sourceAsset: targetAsset,
        targetAsset: fiatCurrency
      },
      sourceAmount: fiatAmount,
      walletAddress
    };
    try {
      const res = await apiFetch('POST', '/ramp/quote', reqBody);
      setQuoteResponse(res);
      setStepLog('quote', {
        req: reqBody,
        res
      });
      setStep('quote', 'done');
      setStep('order', 'active');
    } catch (e) {
      setStepLog('quote', {
        req: reqBody,
        error: e.message
      });
      setStep('quote', 'error');
    } finally {
      setBusy(false);
    }
  };
  const invalidateQuote = () => {
    setQuoteResponse(null);
    setStep('quote', walletAddress && apiKey && customerId ? 'active' : 'idle');
    setStep('order', 'idle');
  };
  const onDirectionChange = value => {
    setDirection(value);
    if (quoteResponse) invalidateQuote();
  };
  const onTargetAssetChange = value => {
    setTargetAsset(value);
    if (quoteResponse) invalidateQuote();
  };
  const onFiatAmountChange = value => {
    setFiatAmount(value);
    if (quoteResponse) invalidateQuote();
  };
  const handleOrder = async () => {
    if (!quoteResponse) {
      alert('Get a quote first.');
      return;
    }
    if (!walletId) {
      alert('Provision a wallet first.');
      return;
    }
    if (!selectedBankAccountId) {
      alert('Select a bank account above. Your org needs at least one registered bank account.');
      return;
    }
    setBusy(true);
    setStep('order', 'active');
    setApproval(null);
    setApprovalSubmitted(false);
    setIsClaimableApproval(false);
    setFinalOrder(null);
    const newOrderId = uuid();
    setOrderId(newOrderId);
    const quotedIsOnramp = quoteResponse.quoteAssets?.type === 'onramp';
    const reqBody = {
      orderId: newOrderId,
      bankAccountId: selectedBankAccountId,
      quoteId: quoteResponse.quoteId,
      cryptoWalletId: walletId
    };
    try {
      const res = await apiFetch('POST', '/ramp/order', reqBody);
      setOrderResponse(res);
      setStepLog('order', {
        req: reqBody,
        res
      });
      setStep('order', 'done');
      if (quotedIsOnramp && isSandbox) {
        setStep('fiat', 'active');
      } else {
        setStep('fiat', 'done');
        setStep('watch', 'active');
        startPolling(newOrderId);
      }
    } catch (e) {
      setStepLog('order', {
        req: reqBody,
        error: e.message
      });
      setStep('order', 'error');
    } finally {
      setBusy(false);
    }
  };
  const handleFiat = async () => {
    if (!orderId) {
      alert('Create an order first.');
      return;
    }
    setBusy(true);
    setStep('fiat', 'active');
    const reqBody = {
      orderId
    };
    try {
      await apiFetch('POST', '/ramp/order/fiat_received', reqBody);
      setStepLog('fiat', {
        req: reqBody,
        res: {
          note: 'Fiat received event fired. Approval will arrive shortly.'
        }
      });
      setStep('fiat', 'done');
      setStep('watch', 'active');
      startPolling(orderId);
    } catch (e) {
      setStepLog('fiat', {
        req: reqBody,
        error: e.message
      });
      setStep('fiat', 'error');
    } finally {
      setBusy(false);
    }
  };
  const startPolling = useCallback(oid => {
    pollRunRef.current += 1;
    const run = pollRunRef.current;
    pollingRef.current = true;
    const poll = async () => {
      let lastSnap = '';
      let settled = false;
      let sawApproval = false;
      for (let i = 0; i < 180 && pollRunRef.current === run; i++) {
        await new Promise(r => setTimeout(r, 2500));
        if (pollRunRef.current !== run) break;
        let order;
        try {
          order = await apiFetch('GET', `/ramp/order/${oid}`);
        } catch {
          continue;
        }
        const snap = JSON.stringify({
          s: order.status,
          a: order.approval?.approvalMessageId,
          cb: order.stellarClaimableBalanceId
        });
        if (snap === lastSnap) continue;
        lastSnap = snap;
        setStepLog('watch', {
          req: `GET /ramp/order/${oid}`,
          res: order
        });
        if (order.approval && order.approval.approvalMessageId) {
          sawApproval = true;
          setApproval(order.approval);
          setIsClaimableApproval(!!order.stellarClaimableBalanceId);
          setStep('sign', 'active');
        }
        if (['completed', 'failed', 'canceled'].includes(order.status)) {
          settled = true;
          setFinalOrder(order);
          setStep('watch', 'done');
          setStep('result', 'done');
          break;
        }
      }
      if (pollRunRef.current === run) {
        pollingRef.current = false;
        if (!settled) {
          if (sawApproval) {
            setStep('watch', 'done');
            setStepLog('watch', {
              res: {
                note: 'Auto-poll ended (~7.5 min). Approval is ready; sign below or use Refresh to re-read the order.'
              }
            });
          } else {
            setStep('watch', 'error');
            setStepLog('watch', {
              error: 'Auto-poll ended (~7.5 min) without an approval or terminal status. Use Refresh to check the order.'
            });
          }
        }
      }
    };
    poll();
  }, [apiFetch]);
  const handleRefresh = async () => {
    if (!orderId) return;
    try {
      const order = await apiFetch('GET', `/ramp/order/${orderId}`);
      setStepLog('watch', {
        req: `GET /ramp/order/${orderId}`,
        res: order
      });
      if (order.approval && order.approval.approvalMessageId) {
        setApproval(order.approval);
        setIsClaimableApproval(!!order.stellarClaimableBalanceId);
        setStep('sign', 'active');
      }
      if (['completed', 'failed', 'canceled'].includes(order.status)) {
        setFinalOrder(order);
        setStep('watch', 'done');
        setStep('result', 'done');
      }
      if (!pollingRef.current && orderId) startPolling(orderId);
    } catch {}
  };
  const handleSign = async () => {
    if (!approval || !signKeyRef.current) return;
    setBusy(true);
    setStep('sign', 'active');
    try {
      const fresh = await apiFetch('GET', `/ramp/order/${orderId}`);
      if (!fresh.approval) throw new Error('No pending approval on the order.');
      const freshApproval = fresh.approval;
      setApproval(freshApproval);
      const sig = await signApproval(freshApproval.approvalMessage, signKeyRef.current);
      const reqBody = {
        approvalMessageId: freshApproval.approvalMessageId,
        approvalMessage: freshApproval.approvalMessage,
        signature: sig
      };
      const res = await apiFetch('POST', `/ramp/order/${orderId}/approvals`, reqBody);
      setStepLog('sign', {
        req: reqBody,
        res
      });
      setApprovalSubmitted(true);
      setStep('sign', 'done');
      setStep('result', 'active');
      if (!pollingRef.current) startPolling(orderId);
    } catch (e) {
      setStepLog('sign', {
        error: e.message
      });
      setStep('sign', 'error');
    } finally {
      setBusy(false);
    }
  };
  const handleResult = async () => {
    if (!orderId) return;
    setBusy(true);
    try {
      const order = await apiFetch('GET', `/ramp/order/${orderId}`);
      setFinalOrder(order);
      setStepLog('result', {
        req: `GET /ramp/order/${orderId}`,
        res: order
      });
      if (['completed', 'failed', 'canceled'].includes(order.status)) {
        setStep('result', 'done');
      } else {
        setStep('result', 'active');
        if (!pollingRef.current) startPolling(orderId);
      }
    } catch (e) {
      setStepLog('result', {
        error: e.message
      });
      setStep('result', 'error');
    } finally {
      setBusy(false);
    }
  };
  const inputStyle = {
    width: '100%',
    padding: '8px 10px',
    borderRadius: 4,
    border: '1px solid #d1d5db',
    fontSize: 13,
    fontFamily: _palette.mono,
    boxSizing: 'border-box'
  };
  const selectStyle = {
    ...inputStyle,
    backgroundColor: 'white',
    cursor: 'pointer',
    appearance: 'auto',
    WebkitAppearance: 'menulist',
    MozAppearance: 'menulist'
  };
  const labelStyle = {
    display: 'block',
    fontSize: 12,
    fontWeight: 600,
    color: '#374151',
    marginBottom: 4
  };
  const isOnramp = direction === 'onramp';
  const quotedIsOnramp = quoteResponse?.quoteAssets?.type ? quoteResponse.quoteAssets.type === 'onramp' : isOnramp;
  const showFiatStep = quotedIsOnramp && isSandbox;
  const cardProps = {
    steps,
    busy,
    onRefresh: handleRefresh
  };
  return <div style={{
    padding: 20,
    border: `1px solid ${_palette.border}`,
    borderRadius: 8,
    marginTop: 20
  }}>

      {}
      <div style={{
    display: 'flex',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 20
  }}>
        <h3 style={{
    margin: 0
  }}>Interactive Embedded Wallet Tutorial</h3>
        {sandboxOnly ? <span style={{
    fontSize: 12,
    fontWeight: 600,
    color: _palette.greenText,
    padding: '4px 10px',
    backgroundColor: '#f0fdf4',
    borderRadius: 999,
    border: '1px solid #bbf7d0'
  }}>
            Sandbox only
          </span> : <div style={{
    display: 'flex',
    alignItems: 'center',
    gap: 8
  }}>
            <span style={{
    fontSize: 14,
    color: isSandbox ? '#111827' : _palette.muted,
    fontWeight: isSandbox ? 600 : 400
  }}>Sandbox</span>
            <button onClick={() => setEnvSandbox(!envSandbox)} style={{
    position: 'relative',
    width: 48,
    height: 24,
    borderRadius: 12,
    border: 'none',
    backgroundColor: isSandbox ? _palette.greenDot : _palette.blue,
    cursor: 'pointer',
    transition: 'background-color 0.2s'
  }}>
              <span style={{
    position: 'absolute',
    top: 2,
    left: isSandbox ? 2 : 26,
    width: 20,
    height: 20,
    borderRadius: '50%',
    backgroundColor: 'white',
    transition: 'left 0.2s'
  }} />
            </button>
            <span style={{
    fontSize: 14,
    color: isSandbox ? _palette.muted : '#111827',
    fontWeight: isSandbox ? 400 : 600
  }}>Production</span>
          </div>}
      </div>

      {}
      <div style={{
    marginBottom: 20,
    padding: 16,
    backgroundColor: '#f3f4f6',
    borderRadius: 6
  }}>
        <div style={{
    marginBottom: 12
  }}>
          <span style={{
    fontSize: 12,
    color: _palette.muted
  }}>Host: </span>
          <code style={{
    fontSize: 12
  }}>{baseUrl}</code>
        </div>
        <div>
          <div style={{
    display: 'flex',
    alignItems: 'baseline',
    gap: 8,
    marginBottom: 8
  }}>
            <label style={{
    fontWeight: 600
  }}>API Key</label>
            <span style={{
    fontSize: 12,
    color: _palette.muted
  }}>
              Find it under API Keys at{' '}
              <a href={isSandbox ? 'https://devnet.etherfuse.com/ramp?view=manage' : 'https://app.etherfuse.com/ramp?view=manage'} target="_blank" rel="noopener noreferrer" style={{
    color: _palette.blue
  }}>
                {isSandbox ? 'devnet.etherfuse.com/ramp' : 'app.etherfuse.com/ramp'}
              </a>
            </span>
          </div>
          <input type="text" value={apiKey} onChange={e => handleApiKeyChange(e.target.value)} placeholder="Paste your API key (api_sand:...)" style={{
    width: '100%',
    padding: 10,
    borderRadius: 4,
    border: `1px solid ${prodKeyBlocked ? '#fca5a5' : '#d1d5db'}`,
    fontSize: 14,
    fontFamily: 'monospace',
    boxSizing: 'border-box'
  }} />
        </div>
        {prodKeyBlocked && <div style={{
    marginTop: 8,
    padding: '8px 12px',
    backgroundColor: _palette.redBg,
    borderRadius: 4,
    border: '1px solid #fecaca',
    fontSize: 12,
    color: _palette.redText
  }}>
            Production keys are not accepted in this playground. Use a sandbox key from{' '}
            <a href="https://devnet.etherfuse.com/ramp?view=manage" target="_blank" rel="noopener noreferrer" style={{
    color: _palette.redText,
    textDecoration: 'underline'
  }}>
              devnet.etherfuse.com/ramp
            </a>.
          </div>}
        {orgId && <div style={{
    marginTop: 12,
    padding: '8px 12px',
    backgroundColor: '#f0fdf4',
    borderRadius: 4,
    border: '1px solid #bbf7d0'
  }}>
            <span style={{
    fontSize: 12,
    color: _palette.greenText
  }}>
              <strong>Org ID:</strong> {orgId}
            </span>
          </div>}
        <p style={{
    fontSize: 12,
    color: _palette.muted,
    marginTop: 12,
    marginBottom: 0
  }}>
          Your API key is stored in session storage and cleared when you close the tab.
        </p>
      </div>

      {}
      {StepCard({
    ...cardProps,
    name: 'keygen',
    num: 1,
    title: 'Generate owner keypair',
    subtitle: 'WebCrypto P-256, in-browser',
    action: handleGenKey,
    actionLabel: 'Generate signing key',
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            A P-256 ECDSA keypair is generated in your browser using the Web Crypto API. The private key never leaves this page. Provision sends the public key as a JWK (<code>signerPublicKeyJwk</code>); the compressed hex below is the normalized form Etherfuse stores. Regenerating a key clears any prior wallet/quote/order state in this tab.
          </p>
          {signerPublicKey && <div style={{
      padding: '8px 12px',
      backgroundColor: '#f0fdf4',
      borderRadius: 4,
      border: '1px solid #bbf7d0',
      fontSize: 12,
      fontFamily: _palette.mono,
      color: _palette.greenText,
      wordBreak: 'break-all'
    }}>
              <strong>signerPublicKey:</strong> {signerPublicKey}
            </div>}
          {privateKeyPem && <div style={{
      marginTop: 8,
      padding: '8px 12px',
      backgroundColor: '#fff7ed',
      borderRadius: 4,
      border: '1px solid #fed7aa'
    }}>
              <div style={{
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
      gap: 8
    }}>
                <span style={{
      fontSize: 12,
      fontWeight: 600,
      color: '#9a3412'
    }}>Private signing key (keep secret; never sent to Etherfuse)</span>
                <button onClick={() => setShowPrivateKey(!showPrivateKey)} style={{
      padding: '3px 10px',
      borderRadius: 6,
      border: '1px solid #fed7aa',
      backgroundColor: 'transparent',
      color: '#9a3412',
      fontSize: 12,
      cursor: 'pointer',
      whiteSpace: 'nowrap'
    }}>
                  {showPrivateKey ? 'Hide' : 'Show'}
                </button>
              </div>
              {showPrivateKey ? <pre style={{
      ..._codeStyle,
      marginTop: 8,
      backgroundColor: '#fffbeb',
      border: '1px solid #fed7aa',
      color: '#7c2d12'
    }}>{privateKeyPem}</pre> : <div style={{
      marginTop: 8,
      fontFamily: _palette.mono,
      fontSize: 16,
      color: '#9a3412',
      letterSpacing: 3,
      userSelect: 'none',
      wordBreak: 'break-all'
    }}>{('•').repeat(44)}</div>}
            </div>}
          {LogPanel({
      stepName: 'keygen',
      log
    })}
        </>
  })}

      {}
      {StepCard({
    ...cardProps,
    name: 'provision',
    num: 2,
    title: 'Provision wallet',
    subtitle: 'POST /ramp/wallet',
    action: handleProvision,
    actionLabel: 'Provision',
    actionDisabled: !signerPublicKey || !apiKey,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            Provision a new embedded wallet tied to your owner key. The request sends <code>signerPublicKeyJwk</code> (browser-native); server-side integrations typically use <code>signerPublicKeyPem</code> or compressed hex. The wallet gets a Stellar address (G...) and can only ever be approved by the key you just generated.
          </p>
          {walletAddress && <div style={{
      padding: '8px 12px',
      backgroundColor: '#f0fdf4',
      borderRadius: 4,
      border: '1px solid #bbf7d0',
      fontSize: 12,
      fontFamily: _palette.mono,
      color: _palette.greenText,
      wordBreak: 'break-all'
    }}>
              <strong>Wallet address:</strong> {walletAddress}
            </div>}
          {LogPanel({
      stepName: 'provision',
      log
    })}
        </>
  })}

      {}
      <div style={{
    marginBottom: 20,
    padding: 14,
    backgroundColor: _palette.panelAlt,
    borderRadius: 8,
    border: `1px solid ${_palette.border}`
  }}>
        <div style={{
    display: 'grid',
    gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
    gap: 12
  }}>
          <div>
            <label style={labelStyle}>Direction</label>
            <select value={direction} onChange={e => onDirectionChange(e.target.value)} style={selectStyle}>
              <option value="onramp">Onramp (fiat to token)</option>
              <option value="offramp">Offramp (token to fiat)</option>
            </select>
          </div>
          <div>
            <label style={labelStyle}>Stellar Asset</label>
            <select value={targetAsset} onChange={e => onTargetAssetChange(e.target.value)} style={selectStyle}>
              {assets.map(a => <option key={a.tokenIdentifier} value={a.tokenIdentifier}>
                  {a.bondSymbol || a.tokenIdentifier.slice(0, 12)} ({a.fxRampCurrency})
                </option>)}
              {assets.length === 0 && <option value="">{walletAddress ? 'Loading assets...' : 'Provision wallet first'}</option>}
            </select>
            {assetsError && <div style={{
    marginTop: 6,
    fontSize: 12,
    color: '#b91c1c'
  }}>
                {assetsError}. See <a href="/api-reference/ramp/assets" style={{
    color: '#2563eb'
  }}>GET /ramp/assets</a>.
              </div>}
          </div>
          <div>
            <label style={labelStyle}>Amount ({isOnramp ? fiatCurrency : assets.find(a => a.tokenIdentifier === targetAsset)?.bondSymbol || 'token'})</label>
            <input type="text" value={fiatAmount} onChange={e => onFiatAmountChange(e.target.value)} style={inputStyle} />
          </div>
          <div>
            <label style={labelStyle}>Bank account</label>
            {bankAccounts.length > 0 ? <select value={selectedBankAccountId} onChange={e => setSelectedBankAccountId(e.target.value)} style={selectStyle}>
                {bankAccounts.map(a => <option key={a.bankAccountId} value={a.bankAccountId}>
                    {a.label || a.bankAccountId.slice(0, 8)} ({a.currency || '?'})
                  </option>)}
              </select> : <div style={{
    padding: '7px 10px',
    borderRadius: 4,
    border: '1px solid #fde68a',
    backgroundColor: '#fefce8',
    fontSize: 12,
    color: '#92400e'
  }}>
                {apiKey ? 'No bank accounts found. Register one at the dashboard first.' : 'Enter your API key to load bank accounts.'}
              </div>}
          </div>
        </div>
        {quoteResponse && steps.order !== 'done' && <p style={{
    margin: '10px 0 0',
    fontSize: 12,
    color: '#92400e'
  }}>
            Quote locked. Change direction, asset, or amount to re-quote before creating an order.
          </p>}
      </div>

      {}
      {StepCard({
    ...cardProps,
    name: 'quote',
    num: 3,
    title: 'Get quote',
    subtitle: 'POST /ramp/quote',
    action: handleQuote,
    actionLabel: 'Quote',
    actionDisabled: !walletAddress || !apiKey || !customerId || !targetAsset,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            Quote with <code>walletAddress</code> so the account reserve and trustline are priced into the fee. Fees are drawn from the assets being ramped and shown in the response; the wallet never needs to hold XLM.
          </p>
          {walletAddress && !targetAsset && !assetsError && <p style={{
      margin: '0 0 10px',
      fontSize: 12,
      color: '#92400e'
    }}>Loading Stellar assets from <code>GET /ramp/assets</code>…</p>}
          {assetsError && <p style={{
      margin: '0 0 10px',
      fontSize: 12,
      color: _palette.redText
    }}>{assetsError}</p>}
          {LogPanel({
      stepName: 'quote',
      log
    })}
        </>
  })}

      {}
      {StepCard({
    ...cardProps,
    name: 'order',
    num: 4,
    title: 'Create order',
    subtitle: 'POST /ramp/order',
    action: handleOrder,
    actionLabel: 'Create order',
    actionDisabled: !quoteResponse || !apiKey,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            The order is created from the quoted <code>quoteId</code> and <code>cryptoWalletId</code> only (direction and amounts are not resent). Etherfuse builds the transaction and mints an approval package asynchronously, so it is never returned in-band here; you watch for it next.
          </p>
          {LogPanel({
      stepName: 'order',
      log
    })}
        </>
  })}

      {}
      {showFiatStep && StepCard({
    ...cardProps,
    name: 'fiat',
    num: 5,
    title: 'Simulate fiat received',
    subtitle: 'POST /ramp/order/fiat_received (sandbox)',
    action: handleFiat,
    actionLabel: 'Simulate fiat',
    actionDisabled: !orderResponse || !apiKey,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            Sandbox only. Fires the fiat_received event so the approval package is minted. In production this step is replaced by your real fiat rail triggering the event automatically.
          </p>
          {LogPanel({
      stepName: 'fiat',
      log
    })}
        </>
  })}

      {}
      {StepCard({
    ...cardProps,
    name: 'watch',
    num: showFiatStep ? 6 : 5,
    title: 'Watch for approval',
    subtitle: 'GET /ramp/order/{id} polling',
    showRefresh: !!orderId,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            Polling <code>GET /ramp/order/{'{id}'}</code> every 2.5 seconds. When an approval package arrives the Sign step unlocks. The <code>order_updated</code> webhook is the durable server-to-server path; you can also connect a WebSocket (see the walkthrough).
          </p>
          {steps.watch !== 'idle' && steps.sign === 'idle' && !approval && pollingRef.current && <div style={{
      padding: 10,
      backgroundColor: '#fefce8',
      borderRadius: 4,
      border: '1px solid #fde68a',
      fontSize: 12,
      color: '#92400e'
    }}>
              Waiting for approval package from Etherfuse... (polling)
            </div>}
          {LogPanel({
      stepName: 'watch',
      log
    })}
        </>
  })}

      {}
      {StepCard({
    ...cardProps,
    name: 'sign',
    num: showFiatStep ? 7 : 6,
    title: 'Sign the approval',
    subtitle: 'Non-custodial: your owner key signs',
    action: handleSign,
    actionLabel: 'Sign and submit',
    actionDisabled: !approval || !signKeyRef.current,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            This is the non-custodial leg. The order carries an approval package: <code>approvalMessageId</code>, <code>approvalMessage</code>, and a <code>summary</code> of what is being approved. You sign the exact <code>approvalMessage</code> bytes with the owner key and submit the result. Etherfuse can only propose; it can never sign or move funds alone.
          </p>
          <div style={{
      padding: '8px 12px',
      marginBottom: 10,
      backgroundColor: '#eff6ff',
      borderRadius: 4,
      border: '1px solid #bfdbfe',
      fontSize: 12,
      color: '#1e40af'
    }}>
            <strong>Re-read before signing:</strong> The <code>approvalMessage</code> embeds a timestamp that is re-minted on every read. This component fetches a fresh copy immediately before signing. A stale message is rejected with <code>409</code>.
          </div>
          {isClaimableApproval && <div style={{
      padding: '8px 12px',
      marginBottom: 10,
      backgroundColor: '#fff7ed',
      borderRadius: 4,
      border: '1px solid #fed7aa',
      fontSize: 12,
      color: '#9a3412'
    }}>
              <strong>First receipt:</strong> this approval is a Stellar claimable balance, so signing it also opens the trustline and claims the funds (a <code>ChangeTrust</code> + <code>ClaimClaimableBalance</code> transaction). Subsequent receipts skip this.
            </div>}
          {approval && <div style={{
      marginBottom: 10
    }}>
              <div style={{
      fontSize: 12,
      color: _palette.muted,
      marginBottom: 3
    }}>Approval summary</div>
              <div style={{
      padding: '8px 12px',
      backgroundColor: _palette.panel,
      borderRadius: 4,
      border: `1px solid ${_palette.border}`,
      fontSize: 13,
      color: '#374151'
    }}>
                {approval.summary || 'No summary provided'}
              </div>
            </div>}
          {LogPanel({
      stepName: 'sign',
      log
    })}
        </>
  })}

      {}
      {StepCard({
    ...cardProps,
    name: 'result',
    num: showFiatStep ? 8 : 7,
    title: 'View result',
    subtitle: 'GET /ramp/order/{id}',
    action: handleResult,
    actionLabel: 'Fetch result',
    actionDisabled: !approvalSubmitted || !orderId,
    showRefresh: !!orderId,
    children: <>
          <p style={{
      color: _palette.muted,
      margin: '0 0 10px'
    }}>
            Fetch the final order state. The order reaches <code>completed</code> once settlement lands. Onramps complete shortly after the broadcast; offramps can take a while, so this keeps polling and you can Refresh to re-check. A sandbox offramp may remain <code>created</code> if there is no live fiat rail; verify the burn on Horizon.
          </p>
          {finalOrder && <div>
              <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 6,
      marginBottom: 6
    }}>
                <span style={{
      padding: '2px 6px',
      borderRadius: 4,
      backgroundColor: _palette.greenBg,
      color: _palette.greenText,
      fontSize: 11,
      fontWeight: 500
    }}>
                  {finalOrder.status}
                </span>
                {finalOrder.stellarClaimableBalanceId && <span style={{
      fontSize: 11,
      color: _palette.muted,
      fontFamily: _palette.mono
    }}>claimable: {finalOrder.stellarClaimableBalanceId.slice(0, 16)}...</span>}
              </div>
              <pre style={_codeStyle}>{JSON.stringify(finalOrder, null, 2)}</pre>
            </div>}
          {LogPanel({
      stepName: 'result',
      log
    })}
        </>
  })}


    </div>;
};

This page is **optional developer tooling**, not the primary integration guide. Read [Embedded Wallets](/guides/embedded-wallets) first for concepts and per-step detail. Run the interactive tester, then copy the minimal server-side script below it.

<Info>
  Embedded wallets run on **Stellar** today. Quoting and ordering follow the standard procedure, so this page links out to [Onramps](/guides/testing-onramps) and [Offramps](/guides/testing-offramps) rather than re-teaching them. The minimal script below is the fastest server-side path; the main guide covers concepts and signing detail.
</Info>

## Sandbox tester

<Warning>
  **Sandbox keys only.** Pasting an API key here sends authenticated requests from your browser to the Etherfuse API, the same pattern as the [Swaps](/guides/testing-swaps) tutorial and the API Reference "Try it" playground. Use a **sandbox** API key only. Production integrations must call these endpoints **server-side**; never embed a production API key in client-side code.
</Warning>

<Info>
  The tester stores your sandbox API key in **session storage** for the current browser tab only. It covers the embedded-specific sequence (provision → quote → order → approval signing). For quote and order mechanics shared with any wallet, see the standard ramp guides linked above.
</Info>

Paste your sandbox API key below and the tester generates an owner keypair in the browser, provisions a wallet, quotes, orders, simulates the fiat deposit, watches for the approval, signs it with the generated owner key, and broadcasts. Every request and response is shown inline so you can check your own payloads.

<EmbeddedWalletTutorial sandboxOnly />

## Minimal sandbox onramp

Copy-paste server-side script for a complete embedded-wallet onramp in sandbox: provision with PEM, quote with `walletAddress`, order with `cryptoWalletId`, simulate fiat, poll for the approval, sign, and broadcast. Set `ETHERFUSE_API_KEY`, `BANK_ACCOUNT_ID`, and `TARGET_ASSET` (a Stellar asset `identifier` from [GET /ramp/assets](/api-reference/assets/list-assets)).

```javascript theme={null}
import crypto from "node:crypto";

const sleep = (s) => new Promise((r) => setTimeout(r, s * 1e3));
const id = () => crypto.randomUUID();
const api = async (method, path, body) => {
  const r = await fetch(`https://api.sand.etherfuse.com${path}`, {
    method,
    headers: { Authorization: process.env.ETHERFUSE_API_KEY, "Content-Type": "application/json" },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  const text = await r.text();
  return text ? JSON.parse(text) : {};
};

// GET /ramp/me — customerId for quotes
const { id: customerId } = await api("GET", "/ramp/me");

// Generate owner P-256 keypair (public → provision; private → sign approvals)
const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
const pem = privateKey.export({ type: "pkcs8", format: "pem" });
const sign = (msg) => crypto.createSign("SHA256").update(msg).sign(pem).toString("hex");

async function embeddedOnramp() {
  // POST /ramp/wallet — provision embedded wallet (signerPublicKeyPem)
  const { walletId, publicKey: wallet } = await api("POST", "/ramp/wallet", {
    walletId: id(),
    signer: { signerPublicKeyPem: publicKey.export({ type: "spki", format: "pem" }) },
  });

  // POST /ramp/quote — onramp pricing; walletAddress folds reserve + trustline into fee
  const quote = {
    quoteId: id(), customerId, blockchain: "stellar", walletAddress: wallet, sourceAmount: "100",
    quoteAssets: { type: "onramp", sourceAsset: "MXN", targetAsset: process.env.TARGET_ASSET },
  };
  await api("POST", "/ramp/quote", quote);

  // POST /ramp/order — slim body with quoteId + cryptoWalletId
  const orderId = id();
  await api("POST", "/ramp/order", {
    orderId, quoteId: quote.quoteId, bankAccountId: process.env.BANK_ACCOUNT_ID, cryptoWalletId: walletId,
  });

  // POST /ramp/order/fiat_received — sandbox only (production: your fiat rail fires this)
  await api("POST", "/ramp/order/fiat_received", { orderId });

  // GET /ramp/order + POST .../approvals — poll until completed; sign when approval pending; production should use registered webhooks or WebSockets
  while (true) {
    const order = await api("GET", `/ramp/order/${orderId}`);
    if (order.status === "completed") break;
    if (order.approval?.approvalMessage) {
      const { approvalMessageId, approvalMessage } = order.approval;
      await api("POST", `/ramp/order/${orderId}/approvals`, {
        approvalMessageId, approvalMessage, signature: sign(approvalMessage),
      });
    }
    await sleep(15);
  }
}

embeddedOnramp().catch(console.error);
```

Each commented `api()` call links to the API reference:

| Call                                   | Reference                                                                                                  |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `GET /ramp/me`                         | [Get organization identity](/api-reference/organizations/get-me): use `id` as `customerId`                 |
| `POST /ramp/wallet`                    | [Register wallet](/api-reference/wallets/register-wallet): PEM, hex, or JWK                                |
| `POST /ramp/quote`                     | [Get quote](/api-reference/quotes/get-quote): pass `walletAddress` for reserve/trustline pricing           |
| `POST /ramp/order`                     | [Create order](/api-reference/orders/create-order): `cryptoWalletId` resolves the embedded wallet          |
| `POST /ramp/order/fiat_received`       | [Simulate fiat received](/sandbox-api/fiat-received): **sandbox only**; production waits on your fiat rail |
| `GET /ramp/order/{orderId}`            | [Get order](/api-reference/orders/get-order): re-read right before signing                                 |
| `POST /ramp/order/{orderId}/approvals` | [Submit order approval](/api-reference/orders/submit-order-approval): hex signature over `approvalMessage` |

<Info>
  Offramps follow the same approval loop; there is no `fiat_received` step and the approval appears once Etherfuse builds the burn transaction. See [Offramps](/guides/testing-offramps).
</Info>

## Worked example (test fixture)

The values below are a **documentation fixture only**; do not use this private key in production. They show the same `approvalMessage` signed once, yielding two common hex encodings Etherfuse accepts.

**Bytes to sign** (`approvalMessage`, copy verbatim, including quotes and escaping):

```json theme={null}
{"version":"1","timestamp":"2026-06-18T12:00:00Z","orderId":"4e9b2bd5-af60-4154-cd82-5ebf70913c44"}
```

**Owner private key** (PKCS#8 PEM):

```
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgl0AYSnGsUwCJzf/+
+B78kqY0U1J+0UUUgy2j/u6VWL2hRANCAATfJYawOOCqxGG8ope5LLHdES6LD7zB
n7Uc3rZTsAPHbM3HbOve4wstthHETWeGGD2wKgQz87uyVPnvB72arU9Z
-----END PRIVATE KEY-----
```

**Normalized `signerPublicKey`** (compressed SEC1 hex; what Etherfuse stores after provision, regardless of whether you sent hex, PEM, or JWK):

```
03df2586b038e0aac461bca297b92cb1dd112e8b0fbcc19fb51cdeb653b003c76c
```

**Corresponding public key PEM** (valid as `signerPublicKeyPem` at provision, or for offline verification):

```
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3yWGsDjgqsRhvKKXuSyx3REuiw+8
wZ+1HN62U7ADx2zNx2zr3uMLLbYRxE1nhhg9sCoEM/O7slT57we9mq1PWQ==
-----END PUBLIC KEY-----
```

**Signatures over that message** (submit either one; same underlying `r` and `s`, two hex layouts):

| Encoding    | Typical source                                     | Hex length | Value                                                                                                                                            |
| ----------- | -------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| DER-wrapped | Node, Java, Go, Erlang, OpenSSL                    | 142        | `304502205d5bfd6a52efdaadcd0ac2cff30fab2461b2484fd8e4dc4c894127aaa6678e86022100e814fc2404b1562a28d90121e540d6996e8484003790cd745fc78527ac07b766` |
| Raw `r‖s`   | Browser WebCrypto, JWS ES256 (same bytes as above) | 128        | `5d5bfd6a52efdaadcd0ac2cff30fab2461b2484fd8e4dc4c894127aaa6678e86e814fc2404b1562a28d90121e540d6996e8484003790cd745fc78527ac07b766`               |

Both verify the same ECDSA P-256 / SHA-256 signature. Etherfuse normalizes either layout before broadcast. ECDSA is randomized, so signing again yields different hex, but any valid signature over the same message is accepted.

<Tip>
  To verify the **DER-wrapped** fixture offline, paste the message, PEM public key, and DER signature into [EncodeAll ECDSA Verify](https://encodeall.github.io/ecdsa_verify.html) with curve **P-256** and algorithm **SHA256withECDSA**. The result should be `Valid`. Most online verifiers expect DER, not the 128-character raw layout.
</Tip>

**Submit with server-side hex** (DER-wrapped, typical Node/Java signer):

```json theme={null}
{
  "approvalMessageId": "5fac3ce6-b071-4265-de93-6fcf81a24d55",
  "approvalMessage": "{\"version\":\"1\",\"timestamp\":\"2026-06-18T12:00:00Z\",\"orderId\":\"4e9b2bd5-af60-4154-cd82-5ebf70913c44\"}",
  "publicKey": "03df2586b038e0aac461bca297b92cb1dd112e8b0fbcc19fb51cdeb653b003c76c",
  "signature": "304502205d5bfd6a52efdaadcd0ac2cff30fab2461b2484fd8e4dc4c894127aaa6678e86022100e814fc2404b1562a28d90121e540d6996e8484003790cd745fc78527ac07b766"
}
```

**Submit with browser hex** (raw `r‖s`, typical WebCrypto signer):

```json theme={null}
{
  "approvalMessageId": "5fac3ce6-b071-4265-de93-6fcf81a24d55",
  "approvalMessage": "{\"version\":\"1\",\"timestamp\":\"2026-06-18T12:00:00Z\",\"orderId\":\"4e9b2bd5-af60-4154-cd82-5ebf70913c44\"}",
  "publicKey": "03df2586b038e0aac461bca297b92cb1dd112e8b0fbcc19fb51cdeb653b003c76c",
  "signature": "5d5bfd6a52efdaadcd0ac2cff30fab2461b2484fd8e4dc4c894127aaa6678e86e814fc2404b1562a28d90121e540d6996e8484003790cd745fc78527ac07b766"
}
```

In production, `approvalMessage` and `approvalMessageId` come from a fresh [GET /ramp/order/{orderId}](/api-reference/orders/get-order) read; the fixture above uses a static message for illustration only.

## Related

<CardGroup cols={2}>
  <Card title="Embedded Wallets" icon="wallet" href="/guides/embedded-wallets" />

  <Card title="Stellar" icon="star" href="/guides/chains/stellar" />

  <Card title="Onramps" icon="arrow-up-right-dots" href="/guides/testing-onramps" />

  <Card title="Offramps" icon="arrow-down" href="/guides/testing-offramps" />
</CardGroup>
