End-to-end swap walkthrough

A complete reference implementation of a swap flow. Starts at "user picked a pair and clicked Confirm" and ends at "user has been credited."

Prerequisites

  • A live credential (gspk_live_* + gssk_live_*). See Authentication.
  • A Node.js 18+ server (or any environment with fetch and crypto.randomUUID).
  • The user has provided: from, to, amountFrom, and their payoutAddress. A refundAddress on the from chain is optional — include it if you have one, omit it otherwise.

Architecture

[ user UI ] ── (form submit) ──> [ your server ]
                                       │
                                       ▼
                          [ GhostSwap Partners API /v1 ]
                                       │
                                       ▼
                              [ GhostSwap liquidity layer ]
                                       │
                              (user sends funds on chain)
                                       │
[ your server ] <── (poll /v1/swaps/:id every 30s) ──> [ partners-api ]
        │
        ▼
[ user UI ] (status updates)

Your server is the only thing that ever sees credentials. The user's browser only sees public data: deposit address, amount, status.

Reference implementation

import { randomUUID } from 'node:crypto';
 
const BASE = 'https://partners-api.ghostswap.io';
const AUTH = `Bearer ${process.env.GHOSTSWAP_PUBLIC_KEY}:${process.env.GHOSTSWAP_SECRET}`;
 
class GhostSwapError extends Error {
  constructor({ status, type, code, message, field }) {
    super(`${type}/${code}: ${message}`);
    this.status = status;
    this.type = type;
    this.code = code;
    this.field = field;
  }
}
 
async function api(method, path, { body, idempotencyKey } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      'Authorization': AUTH,
      'Content-Type': 'application/json',
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) {
    const json = await res.json().catch(() => ({ error: {} }));
    throw new GhostSwapError({ status: res.status, ...json.error });
  }
  return res.json();
}
 
async function quote({ from, to, amountFrom }) {
  const { quote } = await api('POST', '/v1/quotes', { body: { from, to, amountFrom } });
  return quote;
}
 
async function validateAddress({ currency, address, extraId }) {
  const { valid, message } = await api('POST', '/v1/addresses/validate', {
    body: { currency, address, ...(extraId ? { extraId } : {}) },
  });
  if (!valid) throw new GhostSwapError({ type: 'validation_error', code: 'invalid_address', message });
}
 
async function createSwap(input) {
  const idempotencyKey = randomUUID();
  // Persist `idempotencyKey` alongside your order BEFORE the API call so you
  // can retry safely on network errors without creating a duplicate.
  await orderStore.update(input.orderId, { idempotencyKey });
 
  const { swap } = await api('POST', '/v1/swaps', {
    body: input,
    idempotencyKey,
  });
  return swap;
}
 
const TERMINAL = new Set(['finished', 'failed', 'refunded', 'overdue', 'expired']);
 
async function pollUntilTerminal(swapId, { onUpdate } = {}) {
  while (true) {
    const { swap } = await api('GET', `/v1/swaps/${swapId}`);
    onUpdate?.(swap);
    if (TERMINAL.has(swap.status)) return swap;
    if (swap.status === 'hold') {
      // KYC review can take hours/days. Slow down polling.
      await new Promise((r) => setTimeout(r, 5 * 60_000));
    } else {
      await new Promise((r) => setTimeout(r, 30_000));
    }
  }
}
 
// Putting it together:
async function runSwap({ orderId, from, to, amountFrom, payoutAddress, refundAddress }) {
  // 1. Quote.
  const q = await quote({ from, to, amountFrom });
  console.log(`Estimated payout: ${q.amountUserReceives} ${to}`);
 
  // 2. Validate the user's address. Catch invalid addresses early.
  await validateAddress({ currency: to, address: payoutAddress });
 
  // 3. Create the swap, persisting the idempotency key first.
  const swap = await createSwap({
    from,
    to,
    amountFrom,
    address: payoutAddress,
    refundAddress,
    partnerReferenceId: orderId,
  });
  console.log(`Swap ${swap.id} created. User must send ${amountFrom} ${from} to ${swap.payinAddress}.`);
 
  // 4. Show payinAddress to the user. They send funds on chain.
  await orderStore.update(orderId, {
    swapId: swap.id,
    payinAddress: swap.payinAddress,
    expectedPayout: swap.amountExpectedTo,
  });
 
  // 5. Poll until terminal. In production, do this in a background job, not
  // inline — this could take hours.
  const final = await pollUntilTerminal(swap.id, {
    onUpdate: (s) => orderStore.update(orderId, { status: s.status }),
  });
 
  // 6. Credit the user (or refund flow) based on terminal status.
  if (final.status === 'finished') {
    await orderStore.update(orderId, { state: 'fulfilled' });
  } else {
    await orderStore.update(orderId, { state: 'failed', failureReason: final.status });
  }
  return final;
}

Production hardening

A few things the example glosses over:

  • Run polling out-of-process. Spin up a worker (BullMQ, Sidekiq, Cloud Tasks) that polls and updates your DB. The HTTP request that creates the swap should return as soon as you have payinAddress; don't make the user wait through the polling loop.
  • Persist the idempotency key BEFORE the API call. If your process crashes between the API call and the response handler, the next run can recover with the same key.
  • Log X-Request-Id from every response. When something goes wrong and you escalate to support, this lets us find the exact request in our logs.
  • Validate amounts client-side against GET /v1/pairs?from=&to= before submitting. Surface "minimum is X" errors before the user is committed.
  • Show the user a copy button for payinAddress. Display a QR code if you can. Fat-fingered addresses are the #1 cause of failed swaps.
  • Handle hold carefully. Tell the user "review in progress; check your email for instructions" — don't promise resolution times. Direct them to security@ghostswap.io if they reach out.

Common failure patterns

What you seeLikely causeFix
validation_error amount_below_min at quote timeUser entered too small a valueDisplay the min from the quote response
validation_error invalid_address at swap creationThe user's payoutAddress is malformed for the target chainValidate address before showing the Confirm button
Swap stuck in waiting for 36+ hoursUser never sent fundsEventually flips to overdue. Stop polling
Swap goes confirmingfailedDeposited amount under minimumRefund flow follows automatically; status will go to refunded
upstream_error upstream_not_configuredServer-side configuration issueEmail support@ghostswap.io with the X-Request-Id
upstream_error provider_credential_pending (HTTP 503)Your dedicated liquidity key is still being activated by our team (1–3 business days from approval)Build against read endpoints in the meantime; swap creation enables once activation lands
upstream_error upstream_bad_response (HTTP 502)Liquidity provider returned non-JSON; auto-retried once internally and still failedRetry once with the same Idempotency-Key

See also