Skip to main content
Cross-border transfers move money between currencies — USD to EUR, USDC to EURC, USD to BRL via Pix, etc. They always need a quote first to lock in the FX rate.

1. Request a quote

const quote = await sly.quotes.create({
  from_currency: 'USD',
  to_currency: 'EUR',
  amount: '1000.00',
  amount_side: 'send',   // "I send $1000" — or "receive" for "they get €X"
});

console.log(quote);
// {
//   id: 'qt_...',
//   rate: '0.9284',
//   send_amount: '1000.00',
//   receive_amount: '928.40',
//   fee: '2.50',
//   expires_at: '...'
// }
Quotes live for ~30 seconds. If the user takes longer to confirm, re-quote.

2. Show the quote to the user (if applicable)

If there’s a human in the loop, show them:
  • “You send $1000.00 USD”
  • “They receive €928.40 EUR”
  • “Fee: $2.50 USD”
  • “This rate expires in 30 seconds”
Even agent-driven flows should log the quote — auditability matters.

3. Execute the transfer

const transfer = await sly.transfers.create({
  type: 'cross_border',
  from_wallet_id: 'wal_usd_source',
  to_wallet_id: 'wal_eur_dest',    // or to_account_id
  quote_id: quote.id,
  memo: 'Q1 consulting invoice',
  idempotency_key: `inv-2026-Q1-${invoiceId}`,
});

console.log(transfer.status);  // 'pending'
The transfer inherits amounts + rate + fee from the quote.
Always use an idempotency_key. If your process crashes after POSTing but before receiving the response, you don’t want a duplicate transfer on retry. Keys are cached 24 hours.

4. Watch it settle

Three options: Webhook (recommended):
// Your webhook handler
if (event.type === 'transfer.completed') {
  await markInvoicePaid(event.data.transfer);
}
SSE (if the originator is an agent):
await agent.connect({
  onTransfer: (t) => {
    if (t.id === transfer.id && t.status === 'completed') markInvoicePaid(t);
  },
});
Poll:
const final = await sly.transfers.waitForSettlement(transfer.id, { timeout: 60_000 });
// final.status === 'completed' | 'failed'
Typical live settlement times:
  • USDC → USDC: ~10 seconds
  • USDC → fiat (ACH): 1-2 business days
  • USD → USDC (on-ramp): 30 seconds to a few minutes
  • USD → EUR bank wire: 1 business day

5. Handle failure

if (final.status === 'failed') {
  console.error(final.failure_reason);   // code + details
  // Common: RAIL_DECLINED, INSUFFICIENT_BALANCE, SANCTIONS_HIT
}
For INSUFFICIENT_BALANCE, funds never left your wallet — retry after topping up. For RAIL_DECLINED, the receiving bank rejected — usually a bad account number or compliance flag. Surface to the user, don’t auto-retry. For SANCTIONS_HIT, the destination hit a sanctions list. Do not retry; escalate to compliance.

Quote expiry

If you wait too long between quote and execute, the transfer returns QUOTE_EXPIRED. Re-quote and try again:
try {
  await sly.transfers.create({ ..., quote_id: quote.id });
} catch (e) {
  if (e.code === 'QUOTE_EXPIRED') {
    quote = await sly.quotes.create(...);
    await sly.transfers.create({ ..., quote_id: quote.id });
  } else throw e;
}
Or just re-quote right before each transfer — quotes are cheap.

Compliance holds

Large cross-border transfers may pause in processing pending compliance review. You’ll see a compliance.review_required webhook. The transfer either:
  • Completes after review (most common)
  • Fails with HIGH_RISK_TRANSACTION (rare — flags for manual resolution)
Don’t build UIs that assume sub-minute settlement for amounts over $10k.

Rails available

Supported per-currency pairs and rails are discoverable:
const pairs = await sly.quotes.getPairs();
// [{ from: 'USD', to: 'EUR', rails: ['wire', 'card'], typical_settlement: 'T+1' }, ...]