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
fetchandcrypto.randomUUID). - The user has provided:
from,to,amountFrom, and theirpayoutAddress. ArefundAddresson thefromchain 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-Idfrom 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
holdcarefully. 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 see | Likely cause | Fix |
|---|---|---|
validation_error amount_below_min at quote time | User entered too small a value | Display the min from the quote response |
validation_error invalid_address at swap creation | The user's payoutAddress is malformed for the target chain | Validate address before showing the Confirm button |
Swap stuck in waiting for 36+ hours | User never sent funds | Eventually flips to overdue. Stop polling |
Swap goes confirming → failed | Deposited amount under minimum | Refund flow follows automatically; status will go to refunded |
upstream_error upstream_not_configured | Server-side configuration issue | Email 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 failed | Retry once with the same Idempotency-Key |
See also
- Idempotency — retry semantics in depth.
- Status lifecycle — every status with partner UX guidance.
- Errors — full error type matrix.
- Security — keep the credential server-side.