# GhostSwap Partners API — full documentation
Source: https://partners.ghostswap.io/docs
This file concatenates every documentation page plus the integration brief. Intended for direct context-stuffing into an LLM. Each section starts with a "## Page: " header so you can navigate by page.
---
## Integration brief (start here)
# GhostSwap Partners API — One-Prompt Integration Brief
You are writing a **server-side** integration with the GhostSwap Partners API. This brief is self-contained: read it, then output a complete, runnable Node.js application that does an end-to-end swap.
## What this brief produces (full app, not a starter)
Following this brief end-to-end, you will produce a **complete, deployable Express + vanilla-JS app** that does:
- Currency picker populated from the live API
- Live quote on amount input (debounced)
- Server-side proxy of every API call (credentials never leave the server)
- Address validation on blur
- Idempotent swap creation (UUID per click, reused on retries)
- Auto-resume polling via `?swap=swp_…` URL param
- Status updates every 30 s until terminal
- Graceful handling of 429 rate limits, validation errors, and upstream failures
Files produced: `package.json`, `.env.example`, `server.js`, `public/index.html`, `public/app.js`. No build step. No frameworks beyond Express. The reference implementation at the bottom of this brief is the full app — copy it as-is and adapt as needed.
## Your task
Build an Express server that proxies the GhostSwap API. The user's flow:
1. Pick a `from` and `to` currency from a dropdown.
2. Type an amount → see a live quote with the user-receive amount, rate, min/max.
3. Enter payout (`to`) and refund (`from`) addresses → validate inline.
4. Click Confirm → see a deposit address (copyable string; no QR code in the reference app — add `qrcode` from npm if you want one).
5. Send funds on chain → poll until the swap reaches a terminal status.
Output: `package.json`, `server.js`, `public/index.html`, `public/app.js`, `.env.example`. No build step. No frameworks beyond Express.
## Required UI elements (do not skip any of these)
The reference implementation contains every element below. If your output is missing any, the app is incomplete and partners cannot ship it. Do not "simplify" — copy the reference and adapt only what the brief explicitly tells you to adapt.
- ☐ **Currency picker** — `
${swap.amountActualFrom ? `
Actual received: ${fmt(swap.amountActualFrom)} ${swap.from.toUpperCase()}
` : ''}
You'll receive ~${fmt(swap.amountExpectedTo)} ${swap.to.toUpperCase()} at
${swap.payoutAddress}
`;
}
const TERMINAL = new Set(['finished', 'failed', 'refunded', 'overdue', 'expired']);
const terminal = (s) => TERMINAL.has(s);
function poll() {
if (!swap || terminal(swap.status)) return;
clearTimeout(pollHandle);
// 5 min on hold (slow it down — usually pending KYC).
// 10 s while the UI tab is visible (real-time feel for end users).
// 30 s when the tab is backgrounded — saves rate budget without losing
// accuracy since the user isn't watching anyway.
const visible = typeof document !== 'undefined' ? !document.hidden : true;
const interval = swap.status === 'hold' ? 300_000 : (visible ? 10_000 : 30_000);
pollHandle = setTimeout(async () => {
try {
const { swap: s } = await api('GET', `/api/swap/${swap.id}`);
swap = s; renderSwap();
} catch (e) { console.error(e); }
if (!terminal(swap.status)) poll();
}, interval);
}
// Re-pace polling whenever the tab visibility flips, so we accelerate the
// moment the user comes back to the page.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => { if (swap && !terminal(swap.status)) poll(); });
}
// Any field change invalidates an in-flight Confirm key AND any prior
// address validation (since the network for `to`/`from` may have changed).
function invalidateValidations() {
window.payoutValid = false;
window.refundValid = false;
$('payoutMsg').textContent = '';
$('refundMsg').textContent = '';
resetConfirmKey();
updateConfirm();
}
$('from').addEventListener('change', () => { invalidateValidations(); refreshQuote(); });
$('to').addEventListener('change', () => { invalidateValidations(); refreshQuote(); });
$('amount').addEventListener('input', () => { resetConfirmKey(); refreshQuote(); });
$('payout').addEventListener('blur', () => validateAddr($('to').value, $('payout').value, $('payoutMsg'), 'payoutValid'));
$('payout').addEventListener('input', resetConfirmKey);
$('refund').addEventListener('blur', () => validateAddr($('from').value, $('refund').value, $('refundMsg'), 'refundValid'));
$('refund').addEventListener('input', resetConfirmKey);
$('confirm').addEventListener('click', confirm);
(async () => {
await loadCurrencies();
// Resume from URL if ?swap=swp_… present
const id = new URLSearchParams(location.search).get('swap');
if (id) {
try { const { swap: s } = await api('GET', `/api/swap/${id}`); swap = s; renderSwap(); poll(); } catch {}
}
})();
```
## Common pitfalls (lessons from real integrations)
These are real failure modes integrators have hit. Avoiding them up-front saves hours.
### Misleading error messages on swap creation
`POST /v1/swaps` may return HTTP 400 with messages like:
```json
{ "error": { "type": "validation_error", "code": "invalid_request",
"message": "Invalid pair: btc-eth not available or temporary disabled",
"upstream_code": -32602 } }
```
**The literal text isn't always the literal cause.** `-32602` is a generic "invalid params" code from the upstream JSON-RPC layer; the message text can wrap several different root causes:
- The `address` or `refundAddress` failed the upstream's stricter creation-time validator (even though `validateAddress` accepted it).
- The amount has unsupported precision for the target chain.
- The pair is genuinely temporarily disabled (rare for major pairs).
- An upstream maintenance window for that pair.
**How to debug fast:**
1. Try creating the swap **without `refundAddress`** first. If that succeeds, the refund address is the issue — try a different format (e.g. legacy `1...`/`3...` instead of bech32 `bc1...` for BTC, checksummed mixed-case for ETH).
2. Round the `amountFrom` to fewer decimal places and retry.
3. Try a different known-good pair like `btc → ltc` to rule out account-level pair gating.
4. If still failing, log the full request body you sent (with credentials redacted) and the response `X-Request-Id` header — that's what support needs to look it up.
### `validateAddress` is more permissive than `createTransaction`
Both endpoints validate addresses, but `createTransaction` runs a stricter chain-specific validator. An address that passes `POST /v1/addresses/validate` can still get rejected at swap creation. Don't assume validation passing means the address will work for the swap.
### Verifying a swap was actually created
When `POST /v1/swaps` returns HTTP 201, the swap is **real**. To verify independently:
```bash
# GET /v1/swaps/:id returns the swap record from GhostSwap's DB.
curl https://partners-api.ghostswap.io/v1/swaps/htpi6bqnazl7hbjd \
-H "Authorization: Bearer $TOKEN"
```
If this returns the swap row with status, it exists. Your own DB / dashboard may lag — `GET /v1/swaps/:id` is the source of truth. The list view `GET /v1/swaps?limit=10` is also useful for confirming "yes, it landed".
### Currency `image` can be null
For some currencies, `currency.image === null`. Render a fallback (initial-letter circle, generic crypto icon, etc.) — don't render a broken ``.
### Currency tickers are case-insensitive on input but lowercase on output
Send `"BTC"` or `"btc"` — both work. The API normalizes to lowercase. Don't `toUpperCase()` the response tickers when comparing — they're always lowercase.
Use the exact tickers from `GET /v1/currencies`. Stablecoin network labels are not always the obvious human abbreviation: USDT on TRON is `usdtrx`, and USDT on Ethereum is `usdt20`. Common aliases like `usdttrx`, `usdttrc20`, and `usdterc20` are accepted for compatibility and canonicalized internally, but new integrations should store and send the returned canonical ticker.
### Some currencies need `extraId` (memo/destination tag)
XRP, XLM, EOS, IOST, STEEM, STX. These are filtered out of `/v1/currencies` until `extraId` pass-through is added on swap creation. If you see one in the list anyway, do NOT create a swap to it without an `extraId` — funds may be stuck.
## Don't do this
- ❌ **Don't put credentials in `public/app.js`.** They appear in page source. All credentialed calls go through your `/api/*` proxy.
- ❌ **Don't generate a new UUID on each retry of the same logical Confirm click.** The whole point of `Idempotency-Key` is that the same key returns the same swap. New key = new swap = duplicate.
- ❌ **Don't poll faster than 10 seconds while the user is watching.** Use 30 seconds when backgrounded or server-side, and a much slower cadence for `hold`.
- ❌ **Don't add `mode: 'fixed'` to this float app.** Fixed-rate is supported, but it needs its own flow (a `rateId` from a fixed quote, a required `refundAddress`) — see **Fixed-rate swaps**. For a plain float swap, omit `mode`.
- ❌ **Don't show raw `amountTo` to the user.** Use `amountUserReceives` — `amountTo - networkFee` is already computed.
- ❌ **Don't store secrets in your database.** Env vars only. Argon2id-hash anything you must persist.
- ❌ **Don't retry on `validation_error` (400).** Surface the message to the user; nothing's transient.
- ❌ **Don't retry on `unprocessable` (422) with code `exchange_not_processable`.** Surface the response `message` to the user verbatim — it matches GhostSwap's own consumer copy and is intentionally short with no remediation hint.
- ❌ **Don't catch and swallow errors silently.** Always show `error.message` to the user (with `error.param` when present).
## Self-check before you declare done
Before producing your final answer, walk through this list against the code you just wrote. If any item fails, **fix it before submitting** — do not output a partially-complete app and call it done.
1. ☐ Does `public/index.html` contain a quote panel (e.g. `
`) and is it populated by `refreshQuote()` on amount input?
2. ☐ Is the Confirm button initially `disabled`, with `updateConfirm()` only enabling it once a quote has loaded, payout has validated, AND refund is either empty or validated? (Empty refund must NOT block Confirm — refund is optional.)
3. ☐ Are payout (always) and refund (only when filled) address fields wired to `/api/validate-address` on blur, with inline ✓ or error? Is the refund field labeled as optional in the UI?
3b. ☐ When the user leaves refund blank, does the swap-creation request body omit `refundAddress` (or pass `undefined`) instead of sending an empty string?
4. ☐ Is the `Idempotency-Key` UUID generated **once per Confirm click** and reused on retries (NOT regenerated)?
5. ☐ Is the `Authorization: Bearer ${PUBLIC}:${SECRET}` header set only in the server proxy, never in browser code?
6. ☐ Is the polling cadence 10 seconds while visible, 30 seconds when backgrounded/server-side, or 5 minutes when status is `hold`?
7. ☐ Does the swap detail panel hide before a swap exists, and unhide after Confirm or `?swap=…` URL restore?
8. ☐ This reference app is float-rate — confirm the swap-creation body omits `mode` (float is the default). `mode: 'fixed'` is a real, supported mode, but it belongs only in a deliberately-built fixed-rate flow with a `rateId`, not in this float app.
9. ☐ Does the error UI surface `error.message` (and `error.param` if present) — never silently swallowed?
10. ☐ Did you copy the reference implementation files from this brief, instead of writing your own simpler version from memory?
If you skipped or "simplified" any of these, reverse the simplification now. Partners need the full flow — a stripped-down form (no quote, no validation, eagerly-enabled Confirm) is unshippable and creates real money loss for end-users.
## Verification
Once the LLM produces the code, the user should be able to:
1. `npm install`
2. `cp .env.example .env` and fill in the three GhostSwap env vars
3. `npm start`
4. Open `http://localhost:3000`
5. Currencies populate. Pick BTC → ETH. Type `0.01`. See a quote with `amountUserReceives`.
6. Type a malformed address → red error inline.
7. Type a real address → green check.
8. Click Confirm → swap detail appears with deposit address + auto-refresh on 10s while visible.
9. Reload the page (URL has `?swap=htpi6bqnazl7hbjd` or `?swap=swp_…` for legacy ids) → swap state restored, polling resumes.
10. If the partner account is not fully activated yet, fee-sensitive quotes and `POST /v1/swaps` return HTTP 503 with `error.code: provider_credential_pending` and a friendly GhostSwap activation message. Currency, pair, and address-validation endpoints remain useful while you finish the rest of the integration.
11. The error code `upstream_bad_response` (HTTP 502) means the liquidity provider returned a non-JSON response. Safe to retry once on read endpoints. On `POST /v1/swaps`, do **not** auto-retry — call `GET /v1/swaps?limit=20` to check whether the swap was created (look for your `partnerReferenceId`) before deciding whether to retry with a new `Idempotency-Key` or escalate to support.
## Reference
Live API base: `https://partners-api.ghostswap.io`
Full docs (this brief is the condensed form): `https://partners.ghostswap.io/docs`
Support — Telegram: https://t.me/ghostswap1
Support — email: support@ghostswap.io
AML/KYC holds (user-facing): support@ghostswap.io
---
## Page: /docs
URL: https://partners.ghostswap.io/docs
# GhostSwap Partners API
Build crypto-to-crypto swap flows into your product with a single Bearer-authenticated REST API. Server-to-server, idempotent, rate-limited, with a polled status lifecycle. No signing keys to manage — GhostSwap handles liquidity routing on your behalf.
Quickstart →
Five steps from credential to live swap. cURL + JavaScript.
Authentication →
How to issue, rotate, and protect your API credentials.
Swaps API →
Create, list, and inspect swaps. Idempotent by default.
End-to-end guide →
Full walkthrough: quote → validate → create → poll.
## What you get
- **One Bearer token, one base URL.** No JSON-RPC. No upstream key management.
- **Idempotent swap creation.** Safe to retry. We deduplicate on `(credential, Idempotency-Key)` for 24 hours.
- **Generous rate limits** — 120 RPS per source IP and 30 RPS per credential, both enforced today. Standard `RateLimit-*` headers on every response so your client can self-throttle. See [Rate limits](/docs/concepts/rate-limits) for retry guidance. We absorb upstream limits so your traffic stays smooth.
- **Real-time-ish status polling.** Background workers update swap status every 30s upstream; poll every 10s while the user is watching and 30s in background/server-side jobs.
- **Per-organization attribution.** Every swap is tagged with your `org_id` so commission tracking is automatic.
## How a swap works
1. Your server calls `POST /v1/quotes` with the pair and amount. We return the user-facing receive amount.
2. Your server calls `POST /v1/addresses/validate` to check the user's payout wallet.
3. Your server calls `POST /v1/swaps` with an `Idempotency-Key`. We return a deposit address.
4. You display the deposit address to your user. They send the funds on chain.
5. Your server polls `GET /v1/swaps/:id` until the status is terminal.
6. On `finished`, you credit your user using the actual settlement fields (`amountActualFrom`, `amountActualTo`, hashes). We've already credited your commission ledger from the actual amount received.
## Available integration
The server-to-server API is the production launch surface. Hosted widgets are
temporarily unavailable while we harden the signed-session model.
## Need help?
Ping us on [Telegram](https://t.me/ghostswap1) for quick integration questions, or email **support@ghostswap.io** for anything else — credentials, payouts, paper-trail issues, or AML/KYC holds on individual swaps (these are routed to our compliance team). Partners with a dashboard login can also use [/dashboard/help](/dashboard/help).
---
## Page: /docs/quickstart
URL: https://partners.ghostswap.io/docs/quickstart
# Quickstart
You'll go from "no credential" to "swap created" in five steps. ~15 minutes. Server-side only — never put credentials in browser code.
## ⚡ Fastest path: have an AI write it for you
Click the **⌘ Copy for LLMs** button (top right of every docs page, right of the page title) — it copies a 20 KB self-contained brief to your clipboard. Paste it into Claude or ChatGPT with a prompt like:
> _Build me a Node.js Express server that integrates this API end-to-end. The user picks two currencies, sees a quote, enters payout/refund addresses, confirms, and watches the status update until terminal. Use the reference implementation in the brief as a starting point._
The brief includes a complete working `package.json`, `server.js`, `public/index.html`, and `public/app.js` — the AI just adapts it to your project. ~30 seconds from copy to working code.
If you'd rather build it by hand, continue below.
## 1. Get a credential
1. Sign in at [partners.ghostswap.io/dashboard](https://partners.ghostswap.io/dashboard).
2. Submit your application (business name, website, expected monthly volume). A GhostSwap admin will approve.
3. Once approved (status `active`), open **API Credentials** and click **Create live credential**.
4. Copy both values: the **public key** (`gspk_live_*`, always recoverable from this page) and the **secret** (`gssk_live_*`, shown only this once). Your client joins them with a colon for the `Authorization` header — see the code sample below. If you lose the secret later, you can re-view it via the **Reveal secret** button on the same page (audit-logged on our side).
## 2. Make your first call
```js
const BASE = 'https://partners-api.ghostswap.io';
const AUTH = `Bearer ${process.env.GHOSTSWAP_PUBLIC_KEY}:${process.env.GHOSTSWAP_SECRET}`;
const res = await fetch(`${BASE}/v1/currencies?lite=true`, {
headers: { 'Authorization': AUTH },
});
const { currencies } = await res.json();
console.log(currencies); // ["btc", "eth", "ltc", ...]
```
cURL equivalent:
```bash
curl https://partners-api.ghostswap.io/v1/currencies?lite=true \
-H "Authorization: Bearer gspk_live_...:gssk_live_..."
```
## 3. Get a quote
```js
const res = await fetch(`${BASE}/v1/quotes`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'btc', to: 'eth', amountFrom: '0.01' }),
});
const { quote } = await res.json();
// quote.amountUserReceives is the headline number to display.
```
## 4. Create a swap (idempotent)
```js
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
from: 'btc',
to: 'eth',
amountFrom: '0.01',
address: '0xUserPayoutAddress...',
// refundAddress is optional. Include it if you have one on the `from`
// chain; omit it otherwise — your end-user can still receive funds.
refundAddress: 'bc1qUserRefundAddress...',
partnerReferenceId: 'order_42',
}),
});
const { swap } = await res.json();
// Display swap.payinAddress to your user.
```
## 5. Poll for status
```js
const TERMINAL = new Set(['finished', 'failed', 'refunded', 'overdue', 'expired']);
async function poll(id) {
while (true) {
const r = await fetch(`${BASE}/v1/swaps/${id}`, { headers: { 'Authorization': AUTH } });
const { swap } = await r.json();
console.log(`Status: ${swap.status}`);
if (TERMINAL.has(swap.status)) return swap;
await new Promise((res) => setTimeout(res, 30_000));
}
}
```
When `swap.status === 'finished'`, your user has been credited at their `payoutAddress`.
## What's next
- [End-to-end guide](/docs/guides/end-to-end-swap) — full reference walkthrough with error handling.
- [Status lifecycle](/docs/concepts/status-lifecycle) — every state transition and what each means.
- [Idempotency](/docs/concepts/idempotency) — required rules, deduplication semantics.
- [Errors](/docs/concepts/errors) — error envelope and how to recover from each type.
---
## Page: /docs/auth
URL: https://partners.ghostswap.io/docs/auth
# Authentication
Every request to `/v1/*` requires an `Authorization: Bearer` header. The token is your **public key** and **secret**, joined by a single colon.
```
Authorization: Bearer gspk_live_<32 hex>:gssk_live_<48 hex>
```
The dashboard hands you the two values separately at credential creation — your client joins them with `:` for the header.
## How a request actually flows
You make ONE call. We handle the rest:
```
Your server GhostSwap Liquidity layer
(cryptographic
signing handled
entirely by us)
│
│ GET /v1/currencies
│ Authorization: Bearer :
├──────────────────────────►│
│ │ 1. Look up credential by gspk_live_*
│ │ 2. argon2-verify gssk_live_* against hash
│ │ 3. Build internal request
│ │ 4. Sign with our keypair
│ ├──────────────────────────►│
│ │ │ Verify signature,
│ │ │ fetch currencies
│ │◄──────────────────────────┤
│ │ 5. Translate to our shape
│ 200 { currencies: [...] } │
│◄──────────────────────────┤
│
```
You never touch upstream signing keys. You never sign anything. You just send Bearer-authenticated HTTPS — exactly like Stripe, Twilio, OpenAI, etc.
## Key shapes
| Field | Format | Visibility |
|---|---|---|
| Public key | `gspk_live_<32 hex>` | Safe to log. Identifies the credential at request-time. Always visible on the credentials list. |
| Secret | `gssk_live_<48 hex>` | Stored as both an argon2id hash (for auth) and AES-256-GCM encrypted (for recovery). Re-viewable from your dashboard via **Reveal secret**. |
## Issuing credentials
In the dashboard at [/dashboard/api-credentials](https://partners.ghostswap.io/dashboard/api-credentials):
1. Your organization must be in `active` status (an admin approves your application first).
2. Click **Create live credential**, give it a descriptive name (`Production`, `Staging`, etc.), and click **Create**.
3. The dashboard returns two values: your **public key** (`gspk_live_*`, recoverable later) and your **secret** (`gssk_live_*`, shown only this once). Your client joins them with a colon for the `Authorization` header.
4. Treat the secret like any production secret — store in a secret manager or env var, never in source control or browser bundles.
The credential is owned by your **organization**, not your individual user account. Any team member of the org can manage credentials.
## Recovering a lost secret
If you lose track of your secret:
1. Visit `/dashboard/api-credentials`
2. Find the credential row → click **Reveal secret**
3. The secret is shown again with a Hide button to collapse. The public key is always visible on the row.
Every reveal is audit-logged on our side. If a credential's secret was created **before this feature shipped** (early-stage credentials), Reveal returns a friendly message explaining that recovery isn't available — revoke + reissue to upgrade to a recoverable credential.
If you suspect a secret has leaked, **revoke** it instead of revealing — that invalidates it across all environments and gives you a fresh credential.
## Rotating
In v1 your account holds **one active credential at a time**. The dashboard hides the "Issue credential" form while an active key exists, so rotation is a deliberate two-step:
1. **Plan a maintenance window.** Between revoke and issue, API calls return HTTP 401. Pick a low-traffic time, or coordinate with us at support@ghostswap.io if you need a hot-cutover.
2. **Revoke the active credential.** From `/dashboard/api-credentials`, hit **Revoke** on the row. Revocation is immediate — the next request using the old credential returns 401 `unauthenticated`.
3. **Issue a fresh credential** in the same dashboard. The form re-appears once the old one is revoked.
4. **Roll the new public key + secret into your environment** and confirm traffic by watching the **Last used** timestamp on the new row.
Revoked credentials cannot be re-activated. We keep them in your history (greyed out) so you can audit which credential signed each swap.
> If you suspect a secret has leaked, revoke immediately — don't worry about the maintenance window. A short period of 401s is much better than a leaked active credential.
## Where to put the token
✅ **Server-side environment variables.** Loaded into your runtime via your hosting provider's secret manager.
✅ **Backend service-to-service traffic** with the bearer in the `Authorization` header.
❌ **Never** in URL query strings — they leak into server logs and browser referrer headers.
❌ **Never** in browser-side code, single-page apps, or mobile apps. Anything served to a client device is recoverable. Proxy through your own backend.
❌ **Never** in source control, even private repos. Use a `.env` file that's gitignored, or a secret manager.
## Security checklist
- [ ] Credentials live in env vars or a secret manager, not in code.
- [ ] You have a tested rotation runbook.
- [ ] You alert on unexpected 401 responses (could indicate a revoked or rotated credential).
- [ ] Your egress traffic is over HTTPS only (TLS 1.2+).
- [ ] You log the response `X-Request-Id` header — useful when escalating to GhostSwap support.
## What's coming
The [roadmap](/docs/roadmap) covers planned credential-management features:
- Test-mode keys (`gssk_test_*`) so you can develop without spending real funds.
- Per-credential IP allowlists.
- Per-credential method scopes (e.g. read-only credentials).
- Last-used IP and user-agent in the dashboard.
---
## Page: /docs/api/currencies
URL: https://partners.ghostswap.io/docs/api/currencies
# Currencies
List currencies that are currently enabled for swap. Use this to populate your "from" / "to" pickers and to read per-currency metadata like icon URLs and required confirmations.
## Query parameters
## Example
```js
// Full metadata
const res = await fetch(`${BASE}/v1/currencies`, { headers: { 'Authorization': AUTH } });
const { currencies } = await res.json();
// Lite (ticker array only)
const liteRes = await fetch(`${BASE}/v1/currencies?lite=true`, { headers: { 'Authorization': AUTH } });
const { currencies: tickers } = await liteRes.json();
```
## Response (default)
```json
{
"currencies": [
{
"ticker": "btc",
"fullName": "Bitcoin",
"enabled": true,
"enabledFrom": true,
"enabledTo": true,
"fixRateEnabled": true,
"payinConfirmations": 2,
"blockchain": "bitcoin",
"blockchainPrecision": 8,
"image": "https://.../btc.svg",
"requiresExtraId": false,
"extraIdName": null
}
]
}
```
## Response (`?lite=true`)
```json
{ "currencies": ["btc", "eth", "ltc", "..."] }
```
## Response fields
## Notes
- The list reflects currencies enabled **today**. Cache for no longer than ~10 minutes; tickers can be temporarily disabled for maintenance.
- Use the exact ticker returned by this endpoint. Some network labels differ from the obvious human name: for example, USDT on TRON is returned as `usdtrx`, and USDT on Ethereum is returned as `usdt20`. For compatibility, the API also accepts common aliases like `usdttrx` / `usdttrc20` and canonicalizes them internally, but new integrations should store the returned ticker.
- Currencies that require a destination tag (XRP, XLM, EOS, IOST, STEEM, STX, BNB Beacon, Cosmos, Hedera, TON, etc.) are filtered out until [extraId support](/docs/roadmap) lands. The filter checks two layers: (1) upstream `extraIdName` metadata, (2) an explicit denylist of tag-requiring chains as a safety net for cases where upstream metadata is incomplete. The response always includes a per-currency `requiresExtraId` boolean so you can switch on the field rather than maintaining your own chain lookup table.
## Errors
| Type | When |
|---|---|
| `unauthenticated` | Missing/invalid `Authorization` header |
| `rate_limited` | Too many requests. Includes `Retry-After` header. See [Rate limits](/docs/concepts/rate-limits). |
| `upstream_error` | Our liquidity layer returned an error (rare on this endpoint) |
See [Errors](/docs/concepts/errors) for the full envelope and recovery strategy.
---
## Page: /docs/api/pairs
URL: https://partners.ghostswap.io/docs/api/pairs
# Pairs
Get the minimum and maximum amounts for a single trading pair. Validate user input against these bounds before requesting a quote — quoting outside the range returns a `validation_error`.
## Query parameters
## Example
```js
const res = await fetch(`${BASE}/v1/pairs?from=btc&to=eth`, {
headers: { 'Authorization': AUTH },
});
const { pair } = await res.json();
console.log(`Min: ${pair.minAmountFloat} BTC, Max: ${pair.maxAmountFloat} BTC`);
```
## Response
```json
{
"pair": {
"from": "btc",
"to": "eth",
"minAmountFloat": "0.0008",
"maxAmountFloat": "5.0",
"minAmountFixed": "0.001",
"maxAmountFixed": "1.5"
}
}
```
## Response fields
## Notes
- Limits change with market conditions; refresh before showing them in your UI.
- Use the `*Float` fields to bound float-rate swaps and the `*Fixed` fields to bound fixed-rate swaps (`mode: "fixed"`).
- Listing all enabled pairs in one call is on the [roadmap](/docs/roadmap). Today you must query pairs individually.
## Errors
| Type | When |
|---|---|
| `validation_error` | `from` or `to` missing/invalid |
| `not_found` | The pair doesn't exist or is temporarily disabled |
| `unauthenticated` | Missing/invalid `Authorization` header |
| `rate_limited` | Too many requests. Includes `Retry-After` header. See [Rate limits](/docs/concepts/rate-limits). |
| `upstream_error` | Our liquidity layer returned an error |
---
## Page: /docs/api/addresses
URL: https://partners.ghostswap.io/docs/api/addresses
# Address validation
Verify a wallet address is well-formed for a given currency before you create a swap. `POST /v1/swaps` runs this internally too — calling it explicitly here lets you give the user inline feedback as they type.
## Request body
## Example
```js
const res = await fetch(`${BASE}/v1/addresses/validate`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ currency: 'eth', address: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e' }),
});
const { valid, message } = await res.json();
```
## Response
On success:
```json
{ "valid": true }
```
On failure:
```json
{ "valid": false, "message": "Address is not a valid ETH address" }
```
## Response fields
## Notes
- This is a syntactic check — it confirms the address parses correctly for the target chain. It does **not** verify the address is funded or under user control.
- Validation is also performed automatically by `POST /v1/swaps`. Calling this endpoint first lets you fail fast with a friendlier error before the swap creation.
## Errors
| Type | When |
|---|---|
| `validation_error` | Body is missing required fields |
| `unauthenticated` | Missing/invalid `Authorization` header |
| `rate_limited` | Too many requests. Includes `Retry-After` header. See [Rate limits](/docs/concepts/rate-limits). |
| `upstream_error` | Our liquidity layer returned an error |
---
## Page: /docs/api/quotes
URL: https://partners.ghostswap.io/docs/api/quotes
# Quotes
Estimate how much a user will receive for a given input amount. Always quote before showing a number — rates and minimums move with market conditions.
## Request body
## Example
```js
const res = await fetch(`${BASE}/v1/quotes`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'btc', to: 'eth', amountFrom: '0.01' }),
});
const { quote } = await res.json();
console.log(`User receives ~${quote.amountUserReceives} ${quote.to}`);
```
## Response
```json
{
"quote": {
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountTo": "0.1532",
"networkFee": "0.0021",
"amountUserReceives": "0.1511",
"rate": "15.32",
"fee": "0.001",
"min": "0.0008",
"max": "5.0",
"mode": "float"
}
}
```
## Response fields
## Fixed-rate quotes
Pass `mode: "fixed"` to **lock** the exchange rate. Instead of an indicative quote you get a **`rateId`** — a short-lived token that pins the rate. Hand that `rateId` to [`POST /v1/swaps`](/docs/api/swaps) and the user is guaranteed the quoted output amount, regardless of how the market moves between quote and deposit.
```js
const res = await fetch(`${BASE}/v1/quotes`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'btc', to: 'eth', amountFrom: '0.01', mode: 'fixed' }),
});
const { quote } = await res.json();
// quote.rateId → pass to POST /v1/swaps
// quote.expiresAt → ISO timestamp; re-quote once it passes
```
A fixed quote response sets `"mode": "fixed"`, carries two extra fields, and omits `fee`:
```json
{
"quote": {
"mode": "fixed",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountTo": "0.1532",
"networkFee": "0.0021",
"amountUserReceives": "0.1511",
"rate": "15.32",
"min": "0.0008",
"max": "5.0",
"rateId": "f7c2a1b9e4d8...",
"expiresAt": "2026-05-21T18:01:00.000Z"
}
}
```
Rules for fixed-rate quotes:
- **The `rateId` is valid for only ~60 seconds.** Create the swap promptly. Once `expiresAt` passes, request a fresh quote — a stale `rateId` is rejected on swap creation with `rate_expired` (HTTP 409).
- **Pass the same `amountFrom` to `POST /v1/swaps` that you quoted.** The locked rate is bound to that exact input amount.
- A fixed quote has no `fee` field — the locked `rate` already accounts for the liquidity-provider fee.
## Notes
- **Always show `amountUserReceives`** as the headline number. Showing raw `amountTo` over-promises by `networkFee`.
- Use tickers from `GET /v1/currencies`; the quote response echoes canonical tickers. Example: USDT on TRON is `usdtrx` (common aliases like `usdttrx` are accepted but normalized).
- Float quotes are **indicative**, not locked. The final amount on `POST /v1/swaps` may differ slightly because we re-quote at swap creation time.
- For a **locked** rate that can't drift between quote and deposit, use fixed-rate quotes (`mode: "fixed"`) — see the section above.
## Errors
| Type / code | When |
|---|---|
| `validation_error` (`amount_below_min`, HTTP 400, `param: amountFrom`) | `amountFrom` is below the pair's minimum. Error message includes the minimum. |
| `validation_error` (`amount_above_max`, HTTP 400, `param: amountFrom`) | `amountFrom` is above the pair's maximum. Error message includes the maximum. |
| `validation_error` (`pair_unsupported`, HTTP 400, `param: pair`) | The `from`/`to` pair is not currently available for the requested mode (`float` or `fixed`). |
| `validation_error` (other) | Missing field or invalid currency. |
| `authentication_error` (`unauthenticated`) | Missing/invalid `Authorization` header. |
| `rate_limit_error` (`rate_limited`) | Per-credential limit exceeded. See [Rate limits](/docs/concepts/rate-limits). |
| `upstream_error` (`provider_credential_pending`, HTTP 503) | Your account is still being activated. Fee-sensitive quotes and swap creation will succeed once activation completes. |
| `upstream_error` (`upstream_empty_quote`, HTTP 502) | Genuine upstream issue (rare — usually `amount_below_min` / `amount_above_max` is returned instead when our pair-bounds lookup succeeds). Back off and retry. |
| `upstream_error` (other) | Generic liquidity-layer issue. Back off and retry. |
---
## Page: /docs/api/swaps
URL: https://partners.ghostswap.io/docs/api/swaps
# Swaps
Three endpoints for the full swap lifecycle. Swap creation is idempotent — safe to retry.
## Create a swap
Validates the destination address, locks an indicative rate, creates the swap, and returns a deposit address you display to your user.
### Headers
### Request body
### Example
```js
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
from: 'btc',
to: 'eth',
amountFrom: '0.01',
address: '0xUserPayoutAddress...',
refundAddress: 'bc1qUserRefundAddress...',
partnerReferenceId: 'order_42',
}),
});
const { swap } = await res.json();
// Display swap.payinAddress to the user. They should send swap.amountFrom there.
// After completion, read swap.amountActualFrom to know what arrived on-chain.
```
### Response (HTTP 201)
```json
{
"swap": {
"id": "htpi6bqnazl7hbjd",
"providerSwapId": "htpi6bqnazl7hbjd",
"status": "waiting",
"mode": "float",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountExpectedFrom": "0.01",
"amountExpectedTo": "0.1532",
"amountActualFrom": null,
"amountActualTo": null,
"networkFee": "0.0021",
"actualNetworkFee": null,
"rate": "15.32",
"payinAddress": "bc1qDepositAddressFromUs...",
"payoutAddress": "0xUserPayoutAddress...",
"refundAddress": "bc1qUserRefundAddress...",
"payinHash": null,
"payoutHash": null,
"moneyReceivedAt": null,
"moneySentAt": null,
"amountAnomalyPct": null,
"partnerReferenceId": "order_42",
"payTill": null,
"createdAt": "2026-04-29T12:00:00.000Z"
}
}
```
### Requested amount vs actual amount
`amountFrom` is the requested deposit amount from swap creation and never changes. Once the swap progresses, GhostSwap snapshots the amount actually received on-chain into `amountActualFrom`. Partner earnings and processed-volume reporting use `amountActualFrom`, not the requested amount, so over-payments and under-payments are accounted correctly.
Fields populated after upstream settlement data is available:
#### About the swap id
The `id` is the canonical identifier for the swap. **The same string identifies the swap in your records, in ours, and in our upstream liquidity provider's records** — when you escalate a stuck swap to `support@ghostswap.io`, paste this `id` verbatim and we forward it straight upstream without a lookup hop.
| Field | Format | Use it for |
|---|---|---|
| `id` | 16-char hex (e.g. `htpi6bqnazl7hbjd`) for swaps created on or after the unified-id rollout; legacy `swp_*` format for historical swaps. Both work everywhere. | All calls back into our API (`GET /v1/swaps/:id`, idempotency-key correlation), and any support escalation. Store **this**. |
| `providerSwapId` | Identical to `id` for new swaps. Kept in the response for backwards compatibility with integrations written against the previous two-id shape. | Nothing new — equal to `id`. Safe to ignore in new code; safe to keep reading if your existing code already does. |
If your integration is older and stored `swp_*` ids before the rollout, those keep working forever — `GET /v1/swaps/{swp_xxxxxxxx}` still resolves correctly. No code change is required on your side to support both formats.
### Fixed-rate swaps
The default swap is **float-rate** — the rate is re-quoted at creation and floats until the deposit confirms. To create a swap at a **locked** rate instead, first get a fixed quote from [`POST /v1/quotes`](/docs/api/quotes) with `mode: "fixed"`, then create the swap with three additions:
- `mode: "fixed"`
- `rateId` — the token from that fixed quote (valid ~60 seconds)
- `refundAddress` — **required** for fixed-rate swaps (it is optional for float)
Send the **same `amountFrom`** you quoted — the locked rate is bound to that exact input amount.
```js
// 1. Fixed quote
const { quote } = await fetch(`${BASE}/v1/quotes`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'btc', to: 'eth', amountFrom: '0.01', mode: 'fixed' }),
}).then((r) => r.json());
// 2. Create the swap with the rateId — promptly, before quote.expiresAt
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
from: 'btc',
to: 'eth',
amountFrom: '0.01',
address: '0xUserPayoutAddress...',
refundAddress: 'bc1qUserRefundAddress...',
mode: 'fixed',
rateId: quote.rateId,
}),
});
const { swap } = await res.json();
```
A fixed-rate swap response sets `"mode": "fixed"` and a non-null **`payTill`** — the deadline by which the user must deposit:
```json
{
"swap": {
"id": "htpi6bqnazl7hbjd",
"providerSwapId": "htpi6bqnazl7hbjd",
"status": "waiting",
"mode": "fixed",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountExpectedFrom": "0.01",
"amountExpectedTo": "0.1532",
"amountActualFrom": null,
"amountActualTo": null,
"networkFee": "0.0021",
"actualNetworkFee": null,
"rate": "15.32",
"payinAddress": "bc1qDepositAddressFromUs...",
"payoutAddress": "0xUserPayoutAddress...",
"refundAddress": "bc1qUserRefundAddress...",
"payinHash": null,
"payoutHash": null,
"moneyReceivedAt": null,
"moneySentAt": null,
"amountAnomalyPct": null,
"partnerReferenceId": "order_42",
"payTill": "2026-05-21T18:15:00.000Z",
"createdAt": "2026-05-21T18:00:00.000Z"
}
}
```
Rules for fixed-rate swaps:
- **Deposit exactly `amountFrom` before `payTill`.** A late or short deposit drops the swap to status `expired` and the locked rate is lost — see [Status lifecycle](/docs/concepts/status-lifecycle).
- **`rateId` is valid ~60 seconds.** If it has lapsed (or was already used) by the time of this call, you get `rate_expired` (HTTP 409). Request a fresh fixed quote and retry **with a new `Idempotency-Key`** — a new `rateId` is a new logical attempt, so reusing the previous key (bound to the expired `rateId`) returns `idempotency_key_mismatch`.
- **`rateId` and `refundAddress` are both required** for `mode: "fixed"`. Omitting them returns `missing_rate_id` / `missing_refund_address` (HTTP 400), checked before any swap is created.
- Float swaps are unaffected — omit `mode` (or send `mode: "float"`) and the request and response shapes are exactly as documented above, with `"mode": "float"` and `"payTill": null`.
---
## Get a swap
Fetches the current state of a swap. Use the `id` returned by the create response.
### Example
```js
const res = await fetch(`${BASE}/v1/swaps/htpi6bqnazl7hbjd`, {
headers: { 'Authorization': AUTH },
});
const { swap } = await res.json();
console.log(swap.status); // "waiting", "confirming", "finished", ...
```
Status updates flow from a background worker that polls upstream every ~30 seconds. Poll this endpoint at any cadence; we return our database state. See [Status lifecycle](/docs/concepts/status-lifecycle).
---
## List swaps
Paginated, most-recent first, scoped to your organization.
### Query parameters
### Example
```js
const res = await fetch(`${BASE}/v1/swaps?limit=50&offset=0`, {
headers: { 'Authorization': AUTH },
});
const { swaps } = await res.json();
```
### Response
```json
{
"swaps": [
{ "id": "htpi6bqnazl7hbjd", "status": "finished", ... },
{ "id": "kx9j3mfp2qaw7rty", "status": "confirming", ... }
]
}
```
---
## Errors
| Type / code | When |
|---|---|
| `validation_error` (`missing_idempotency_key`, HTTP 400) | The `Idempotency-Key` header is required on `POST /v1/swaps`. Generate a UUID v4 once per Confirm action and reuse it on retries — never regenerate per HTTP attempt. |
| `validation_error` (`amount_below_min`, HTTP 400, `param: amountFrom`) | `amountFrom` is below the pair's minimum. Error message includes the actual minimum. Pre-validate locally with `/v1/pairs?from=…&to=…` to avoid this on the swap-creation path. |
| `validation_error` (`amount_above_max`, HTTP 400, `param: amountFrom`) | `amountFrom` is above the pair's maximum. Same pre-validation guidance as `amount_below_min`. |
| `validation_error` (`pair_unsupported`, HTTP 400, `param: pair`) | The `from`/`to` pair is not currently available for the requested mode (`float` or `fixed`). |
| `validation_error` (`missing_rate_id`, HTTP 400) | `mode: "fixed"` was sent without a `rateId`. Get one from `POST /v1/quotes` with `mode: "fixed"` and pass it within ~60 seconds. |
| `validation_error` (`missing_refund_address`, HTTP 400) | `mode: "fixed"` was sent without a `refundAddress`. It is required for fixed-rate swaps. |
| `validation_error` (`field: 'address'`) | Destination address failed upstream validation. |
| `validation_error` (other) | Missing field, invalid currency, generic body validation failure. |
| `authentication_error` (`unauthenticated`, HTTP 401) | Missing or invalid `Authorization` header. Also returned when the credential itself was revoked — issue a new one. |
| `authorization_error` (`org_pending_review`, HTTP 403) | Your organization is still under admin review. The API will start accepting calls once approved. No action needed; you'll be emailed when ready. |
| `authorization_error` (`org_suspended`, HTTP 403) | Your organization has been suspended. The credential itself is fine — contact `support@ghostswap.io`. |
| `authorization_error` (`org_rejected`, HTTP 403) | Your partner application was not approved. Contact `support@ghostswap.io` for details. |
| `not_found` (HTTP 404) | Swap id doesn't exist or doesn't belong to your org. |
| `conflict` (`idempotency_key_mismatch`, HTTP 409) | `Idempotency-Key` reused with a **different** request body. |
| `conflict` (`rate_expired`, HTTP 409) | The fixed-rate `rateId` has expired (~60s window) or was already used. Request a fresh fixed quote and retry. |
| `unprocessable` (`exchange_not_processable`, HTTP 422) | We can't route this specific swap. **Don't retry** — surface the response `message` to your user verbatim. The `message` is identical to what GhostSwap's own consumer product shows in the same case and is intentionally short with no remediation hint. |
| `rate_limit_error` (`rate_limited`, HTTP 429) | Rate limit exceeded. Comes with `Retry-After` and `RateLimit-*` headers. See [Rate limits](/docs/concepts/rate-limits). |
| `upstream_error` (`provider_credential_pending`, HTTP 503) | Your account is still being activated. Fee-sensitive quotes and swap creation enable once activation completes. See [Troubleshooting](/docs/guides/troubleshooting). |
| `upstream_error` (`upstream_bad_response`, HTTP 502) | Liquidity provider returned a non-JSON response. On `POST /v1/swaps` specifically, **do not auto-retry** — call `GET /v1/swaps?limit=20` and check for an existing row with your `partnerReferenceId` first. See [Troubleshooting](/docs/guides/troubleshooting) for the recovery procedure. |
| `upstream_error` (other) | Generic liquidity-layer issue. Back off and retry with the same `Idempotency-Key` (safe on read endpoints; on `POST /v1/swaps`, follow the recovery procedure above). |
See [Errors](/docs/concepts/errors) for the envelope shape and recovery strategy per type.
---
## Page: /docs/concepts/commissions
URL: https://partners.ghostswap.io/docs/concepts/commissions
# Commissions
Every partner sets their own fee at signup. That fee is your partner earning rate on completed swap volume and is tracked per swap in USD.
## Your fee
```
User swaps $1,000 equivalent
Your approved fee: 1.0%
Your estimated earning: $10.00
```
Allowed range for **your fee**: **0% – 4%**. Two decimal places. Set during the application form, locked thereafter (changing requires emailing support@ghostswap.io).
## How the split is enforced
Once your application is approved, GhostSwap provisions a dedicated upstream liquidity key configured for your approved partner fee. The key is set up by our liquidity team during onboarding and stays fixed for the life of the key. All fee-sensitive quotes and swaps from your organization route through that key so the quote shown to your user matches the swap they create.
Activation typically completes within **1-3 business days** of approval. While it's in flight, fee-sensitive quotes and swap creation return a friendly `provider_credential_pending` (HTTP 503). Currency, pair, and address-validation endpoints remain useful while you build the rest of the integration.
You **never** handle the upstream key yourself. It lives encrypted in our infrastructure, signs every swap on your behalf, and is what the upstream pays against.
## When your commission is calculated
Real-time, the moment a swap reaches `finished`:
1. Background worker observes the status transition
2. Snapshots the actual source amount received on-chain
3. Fetches the live USD spot price for the source currency
4. Computes:
```
amount_from_usd = amountActualFrom × spot_price
your_commission = amount_from_usd × (your_fee_percent / 100)
```
5. Inserts an `estimated` row in your commission ledger
You see the new row appear on `/dashboard/earnings` within ~30 seconds of swap completion.
`amountFrom` remains the requested amount from creation. If a user sends more or less than requested, the dashboard and ledger use `amountActualFrom`, the amount actually received, so earnings match the completed on-chain swap.
## Estimated → reviewed
Two-phase accounting:
| State | Meaning |
|---|---|
| `estimated` | Computed at swap-finish using live USD spot. Locked in your currency at that moment. |
| `settled` | GhostSwap reviewed and approved the row for manual payout. |
| `voided` | Rare. Swap was charged-back or refunded after finishing. |
> **v1 status:** the `estimated → settled` flip currently runs as a **manual operations process**. In the dashboard, treat settled rows as reviewed and approved for manual payout.
You're paid in USDT after your reviewed balance crosses the **$100 minimum threshold**. Request payouts from `/dashboard/payouts`; admin reviews and sends the funds, then pastes the on-chain tx hash so you can verify it on the block explorer.
## Per-swap visibility
Every swap row carries:
- `partner_reference_id` (your end-user ID, if you sent one) — so you can see which of your users drove which swap
- `api_credential_id` — which of your `gspk_live_*` credentials created the swap (useful if you have multiple)
- `amount_from` — the requested source amount
- `actual_amount_from` — the amount actually received once known
- `commission_amount_usd` joined in — your earning for that specific swap
`/dashboard/transactions` lets you filter by both. `/dashboard/earnings` rolls them up into per-credential and per-end-user breakdowns.
### Pass `partnerReferenceId` to attribute swaps to your end-users
```js
fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json',
'Idempotency-Key': uuid() },
body: JSON.stringify({
from: 'btc', to: 'eth', amountFrom: '0.01',
address: '0xUserPayoutAddress',
refundAddress: 'bc1qUserRefundAddress',
partnerReferenceId: 'user_42_order_8731', // ← any string up to 120 chars
}),
});
```
The reference id is stored on the swap row and surfaces in:
- `/dashboard/earnings` "Top end users" list
- `/dashboard/transactions` per-row column + filter
- `GET /v1/swaps?…&endUser=user_42` for programmatic queries (planned)
## Worked example
You signed up with `partnerFeePercent: 1.0`. Today three of your end-users do swaps:
| End-user ID | Pair | requested | actual received | spot price | volume USD | Your earning |
|---|---|---|---|---|---|---|
| `user_42` | btc → eth | 0.01 BTC | 0.01 BTC | $65,000 | $650 | **$6.50** |
| `order_8731` | eth → usdt | 5 ETH | 5 ETH | $3,300 | $16,500 | **$165.00** |
| `wallet_99` | usdt → btc | 1,000 USDT | 1,200 USDT | $1.00 | $1,200 | **$12.00** |
Your `estimated` balance: **$183.50**. After GhostSwap reviews it, it flips to `settled` and becomes payable.
## Referral commissions
Beyond the API integration, you can also earn by sharing a referral link. Visitors who arrive on `https://ghostswap.io/?ref={your_code}` get a cookie that tags them as referred by you — every swap they complete on ghostswap.io earns you a commission, with **no integration work required**.
```
End user clicks https://ghostswap.io/?ref=orangefren
→ cookie gs_ref=orangefren (30 days)
→ user does a swap on the ghostswap.io widget
→ 1% of the swap volume credited to orangefren
```
The rate is set per code at admin-approval time (you'll be notified of yours when referrals are enabled on your account). Your referral commission **combines with your API integration commission into one balance** — same `/dashboard/earnings`, same $100 payout threshold, same USDT settlement.
Only `finished` swaps trigger a referral commission. Mid-flight, failed, expired, or refunded swaps earn nothing — same rule as API integration swaps.
### Pick your code
Active partners can claim their own referral slug on the **Overview** page. Go to `/dashboard`, type the slug you want under "Pick your referral code", and click **Claim slug**. Allowed characters: lowercase letters, digits, hyphens, 2–64 chars total. Reserved words (`api`, `admin`, `dashboard`, `ghostswap`, etc.) can't be used. Slugs are first-come-first-served and globally unique — pick something memorable that's tied to your brand.
### See your link
Once you've claimed a slug, your dashboard shows a **"Your referral link"** card on the Overview page with a copy button and 30-day stats (referred swap count, volume, estimated + reviewed earnings).
### Change your referral link
Click **Change my Referral Link** inside the referral card on the Overview page, then confirm. Disabling frees the slug for someone else and shows you the picker again so you can claim a new one. Disabled codes stop earning immediately — visitors who click an old link still land on ghostswap.io, but won't be attributed to you.
## Settlement currency for payouts
USDT is the default for partner payouts. Network selection is admin-configured per partner (TRC-20 default for low fees; ERC-20 available on request).
## What if I want to change my fee later?
Email **support@ghostswap.io** with your `org_public_id` (visible on `/dashboard`) and the new fee. The change requires our liquidity team to reconfigure your dedicated key — typically 24 hours. Existing swaps keep their `partner_fee_percent_at_creation` snapshot, so historical commissions stay locked in.
## See also
- [Status lifecycle](/docs/concepts/status-lifecycle) — when commission entries are written
- [Idempotency](/docs/concepts/idempotency) — partnerReferenceId vs Idempotency-Key
- [Errors](/docs/concepts/errors) — `provider_credential_pending` and how to handle pre-activation
---
## Page: /docs/concepts/idempotency
URL: https://partners.ghostswap.io/docs/concepts/idempotency
# Idempotency
`POST /v1/swaps` is designed for safe retry. Sending the same `Idempotency-Key` with the same request body returns the original swap — your retry won't create a second one.
## How it works
We hash the credential id, the idempotency key, and the request body, and cache the response for **24 hours**. On a repeat call:
| Scenario | Behavior |
|---|---|
| Same key + same body | Returns the original cached response (HTTP 201 with the original `swap.id`) |
| Same key + **different** body | Returns HTTP 409 `conflict` (`code: idempotency_key_mismatch`) — protects against accidental key reuse with mismatched data |
| Same key, original request still in flight | Returns HTTP 409 `conflict` (`code: idempotency_in_progress`) — the first call is still working; back off and retry with the same key after ~1s |
| New key | Creates a fresh swap |
After 24 hours the cache entry expires; reusing the key after that creates a new swap.
### Two layers of protection
We persist both the cached response **and** a hash of the request body on the swap row itself. If the response cache write briefly fails after the upstream swap was created (rare but possible), the next retry still finds the swap row, validates the body hash, and returns the original swap (or a 409 if the body changed). You should not see duplicate swaps from a network blip.
## When to use
Always, on every `POST /v1/swaps` call. There is no good reason to omit it. Generate a UUID v4 per logical attempt — once per "user clicked Confirm Swap", not once per HTTP retry.
## Choosing a key
A UUID v4 (`randomUUID()` in Node, `uuid.uuid4()` in Python) is the right default. The key must be:
- Unique across attempts that are **logically distinct**.
- Stable across HTTP retries of the **same** attempt.
If your service crashes after generating the key but before storing it, that's fine — the next retry will get the cached response on success, or will create the swap fresh on first failure. Either way, exactly one swap exists.
## Examples
### Node.js
```js
async function createSwap(input) {
const idempotencyKey = randomUUID();
// Persist `idempotencyKey` alongside your order before calling the API.
await orderStore.update(input.orderId, { idempotencyKey });
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(input),
});
return res.json();
}
```
### Retry on transient failures
```js
async function createSwapWithRetry(input, attempts = 3) {
const idempotencyKey = randomUUID();
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // SAME key across retries
},
body: JSON.stringify(input),
});
if (res.status >= 500) throw new Error(`Server error ${res.status}`);
return res.json();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
}
```
The same key across retries means you create at most one swap, regardless of how many network errors you hit.
## What's NOT idempotent
Read endpoints (`GET /v1/*`) are inherently safe to retry — no idempotency key needed.
`POST /v1/quotes` is not currently keyed. Quotes are cheap and stateless; you can re-quote freely.
`POST /v1/addresses/validate` is also not keyed.
In v1, only `POST /v1/swaps` requires `Idempotency-Key`.
## See also
- [Errors](/docs/concepts/errors) — `conflict` envelope details.
- [Rate limits](/docs/concepts/rate-limits) — what happens when you retry too fast.
---
## Page: /docs/concepts/rate-limits
URL: https://partners.ghostswap.io/docs/concepts/rate-limits
# Rate limits
We use generous limits so integration work and normal traffic never feel them. Real abuse — credential stuffing, runaway loops, scraping — gets caught before it can hurt anyone else.
| Layer | Limit | Scope | Currently enforced |
|---|---|---|---|
| Pre-auth (IP) | **120 requests/second** | Per source IP, per top-level path (`/v1`, `/partner`, `/admin`, `/widget-api`) | ✅ Yes |
| Per-credential | **30 requests/second** | Each `gspk_live_*` credential | ✅ Yes |
| Global upstream | 10 requests/second | Aggregate across all GhostSwap partners (we absorb this) | ✅ Yes |
The pre-auth IP cap is sized for partners hosted on shared egress (Vercel, Cloudflare Workers, AWS NAT pools, Render) where many origin servers can leave through one source IP. Integration test suites that fire 50+ concurrent requests will almost never see a 429 from us. If you do, it's almost always a runaway loop.
## RateLimit headers on every response
Every response includes RFC-9112 standard headers so your client can self-throttle before ever hitting a 429:
```http
RateLimit-Limit: 120
RateLimit-Remaining: 87
RateLimit-Reset: 1
RateLimit-Policy: 120;w=1
```
| Header | Meaning |
|---|---|
| `RateLimit-Limit` | Cap for the current window |
| `RateLimit-Remaining` | Requests you have left before throttling |
| `RateLimit-Reset` | Seconds until the bucket resets |
| `RateLimit-Policy` | `;w=` |
If `RateLimit-Remaining` drops below 10, slow down. Don't wait for a 429 to react.
## Recognizing 429s
If you do exceed the limit, the response is HTTP 429 with our standard envelope plus a `Retry-After` header:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 1
Content-Type: application/json
X-Request-Id: 7b3c1e9f-...
{
"error": {
"type": "rate_limit_error",
"code": "rate_limited",
"message": "Rate limit exceeded",
"retry_after_ms": 1000
}
}
```
## Backing off
Read `Retry-After` (in seconds) and sleep at least that long before retrying. Use the same `Idempotency-Key` if you're retrying a `POST /v1/swaps` — that way the retry is safe even if the original request succeeded server-side and we return the cached response.
```js
async function withBackoff(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fn();
if (res.status !== 429) return res;
const retryAfter = Number(res.headers.get('Retry-After')) || 1;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error('Rate limit retries exhausted');
}
```
## Avoiding rate limits in normal operation
You shouldn't have to think about rate limits in normal traffic, but a few habits make the API faster too:
- **Cache `GET /v1/currencies`** for ~10 minutes. The list rarely changes minute-to-minute.
- **Quote once per user attempt.** Don't re-quote on every keystroke; debounce by ~500 ms.
- **Poll `GET /v1/swaps/:id` every 10 seconds while the user is watching**, then back off to 30 seconds when the page is backgrounded or when polling from a server-side worker. Use a much slower cadence for `hold`.
- **Stop polling at terminal states.** `finished`, `failed`, `refunded`, `overdue`, `expired` — never poll these again.
## What gets skipped
- `/health` and `/ready` are never rate-limited. Use them for uptime monitoring without worrying about quota.
## Need higher limits?
Email **support@ghostswap.io** (or ping us on [Telegram](https://t.me/ghostswap1)) with your credential id and the sustained RPS you need. We adjust on a case-by-case basis.
## How GhostSwap absorbs the global cap
Our liquidity layer enforces a 10 RPS aggregate cap shared across all GhostSwap partners. We handle this for you:
- The catalog read endpoints are cached in-process: `GET /v1/currencies` for ~60 seconds and `GET /v1/pairs` for ~30 seconds, so partners don't see those 429s.
- Quote and swap-creation calls hit the upstream 1:1 and serialize internally to fit under the cap.
If we ever need to surface a global upstream 429 to you, the error type is `rate_limit_error` with code `upstream_rate_limited` — distinct from your per-credential `rate_limited`.
---
## Page: /docs/concepts/status-lifecycle
URL: https://partners.ghostswap.io/docs/concepts/status-lifecycle
# Status lifecycle
A swap moves through a sequence of statuses from creation to terminal state. Poll `GET /v1/swaps/:id` until terminal.
## Happy path
```
waiting → confirming → exchanging → sending → finished
```
## All statuses
| Status | Terminal | Meaning | Suggested partner UX |
|---|---|---|---|
| `waiting` | no | Awaiting incoming payment to `payinAddress` | Show `"Send to "` with a copy button |
| `confirming` | no | Detected on chain — waiting for confirmations | `"Detected on chain — waiting for N confirmations"` |
| `exchanging` | no | Confirmed; the swap is executing | "Exchanging…" with a spinner |
| `sending` | no | Funds en route to `payoutAddress` | "Sending to your wallet…" |
| `finished` | **yes** | Swap complete; user has been credited | Success state with a link to the payout tx (when available) |
| `failed` | **yes** | Failed — most often the deposit was below minimum | `"Swap failed — "`. Offer support contact |
| `refunded` | **yes** | Funds returned to `refundAddress` (or to the original sending address when `refundAddress` was omitted and the chain supports it) | `"Refunded — see "` |
| `overdue` | **yes** | Float-rate window expired before payin landed | "Window closed. Start a new swap." |
| `expired` | **yes** | Fixed-rate swap — the deposit didn't arrive (or arrived short) before the `payTill` deadline, so the locked rate lapsed | "The fixed-rate window closed — get a fresh quote and start a new swap." |
| `hold` | conditional | AML/KYC review — funds held until verified | Direct user to email **support@ghostswap.io**. Do not promise a resolution time |
## When to stop polling
Stop polling when status is in the terminal set:
```js
const TERMINAL = new Set(['finished', 'failed', 'refunded', 'overdue', 'expired']);
if (TERMINAL.has(swap.status)) {
// Update your DB, credit the user, etc.
return;
}
```
`hold` is a soft-block — keep polling at a slower cadence (every few minutes) since it can resolve to `finished` or `refunded` after manual review.
## How fresh is the status?
A background worker on our side polls upstream every ~30 seconds for any swap not in a terminal state. So your worst-case lag from "user's deposit was confirmed on chain" → "your dashboard shows `confirming`" is ~30 seconds + your poll cadence.
For end-to-end "real-time" feel, poll us every 10 seconds while the user-facing UI is open, 30 seconds when backgrounded or server-side, and every few minutes for `hold`.
## Common transitions
- **`waiting` → `overdue`**: user never sent the funds within the float window. Status flips after ~36 hours of inactivity. Refund flow doesn't apply (no funds were received).
- **`waiting` → `expired`**: the fixed-rate counterpart of `overdue`. On a `mode: "fixed"` swap the user didn't deposit (or deposited too late) before the `payTill` deadline, so the locked rate lapsed. Refund flow doesn't apply when no funds were received; start a new swap from a fresh fixed quote.
- **`waiting` → `confirming` → `failed`**: the user sent an amount under the pair's minimum. Funds are received but cannot be exchanged. Goes to `refunded` shortly after — funds return to `refundAddress` if one was provided, otherwise to the original sending address where the chain supports it (a small number of chains require `refundAddress` for automatic refund and will queue the refund for support otherwise).
- **`confirming` → `hold`**: the deposit triggered an AML flag. Funds are held; the user must complete KYC verification — direct them to support@ghostswap.io.
- **`hold` → `finished`**: KYC cleared, swap completed.
- **`hold` → `refunded`**: KYC failed or the user requested a refund.
## See also
- [Errors](/docs/concepts/errors) — what to show when a status transition fails to fetch.
- [End-to-end swap guide](/docs/guides/end-to-end-swap) — full polling reference implementation.
---
## Page: /docs/concepts/errors
URL: https://partners.ghostswap.io/docs/concepts/errors
# Errors
All non-2xx responses use the same envelope. Inspect `error.type` to decide how to recover.
## Envelope
```json
{
"error": {
"type": "validation_error",
"code": "missing_field",
"message": "from is required",
"param": "from"
}
}
```
| Field | Type | Notes |
|---|---|---|
| `type` | string | High-level category. Drives your retry strategy. |
| `code` | string | Short machine code. Stable; safe to switch on. |
| `message` | string | Human-readable explanation. May change wording over time. |
| `param` | string | Present only on `validation_error`. The request field that's invalid. |
| `retry_after_ms` | number | Present on `rate_limit_error`. How long to wait before retrying. |
| `upstream_code` | number | Present on `upstream_error` when the upstream returned a numeric code. |
Every response also has an `X-Request-Id` header. Log it. Pass it back to GhostSwap support when escalating — we use it to find your request in our logs.
## Error types
These are the values you'll see in `error.type`. Switch on these to drive recovery — they're stable.
| Type | HTTP | Recoverable? | What to do |
|---|---|---|---|
| `validation_error` | 400 | No (without changing input) | Surface to the user; fix the input. Check `error.param` for the bad field. |
| `authentication_error` | 401 | No (without new credentials) | Check the `Authorization` header is present and well-formed. Revoked credentials also return `unauthenticated`. |
| `authorization_error` | 403 | Sometimes | Org isn't `active` yet, or you don't have access to this resource. Code is usually `forbidden` or `org_not_active`. |
| `not_found` | 404 | No | Wrong id or it doesn't belong to your org |
| `conflict` | 409 | Yes | Most commonly `idempotency_key_mismatch` — same `Idempotency-Key` reused with a different body. Change one. |
| `unprocessable` | 422 | Yes (with different input) | Request was well-formed but rejected by a business rule. See `error.code`. |
| `rate_limit_error` | 429 | Yes | Wait `error.retry_after_ms` (or the `Retry-After` header), then retry with the same `Idempotency-Key`. |
| `upstream_error` | 502 / 503 | Yes (transient) | Backoff and retry. Often resolves in seconds. |
| `internal_error` | 500 | Sometimes | Backoff and retry with the same idempotency key. Escalate with `X-Request-Id` if it persists. |
## Common codes
`error.code` is the short machine-readable classifier under each type. These are stable and safe to switch on. Common ones you'll see:
| Code | Type | Meaning |
|---|---|---|
| `invalid_request` | validation_error | Generic bad-shape body (see `error.param`) |
| `missing_field` | validation_error | A required body/query field was absent |
| `invalid_currency` | validation_error | Unknown ticker or currency is temporarily disabled |
| `amount_below_min` | validation_error | `amountFrom` is below the pair's minimum |
| `amount_above_max` | validation_error | `amountFrom` exceeds the pair's maximum |
| `invalid_address` | validation_error | The destination address failed validation |
| `missing_rate_id` | validation_error | A `mode: "fixed"` swap was created without a `rateId` from a fixed quote |
| `missing_refund_address` | validation_error | A `mode: "fixed"` swap was created without the required `refundAddress` |
| `idempotency_key_mismatch` | conflict | Same `Idempotency-Key`, different body |
| `idempotency_in_progress` | conflict | Same `Idempotency-Key` is still processing; retry shortly with the same key |
| `pending_request_exists` | conflict | A previous request of this kind is still in flight (e.g. payouts) |
| `rate_expired` | conflict | A fixed-rate `rateId` expired (~60s window) or was already used — request a fresh fixed quote and retry |
| `unauthenticated` | authentication_error | Missing, malformed, or expired bearer credential |
| `forbidden` | authorization_error | Generic access denied |
| `org_not_active` | authorization_error | The credential's org is `pending_review` / `suspended` / `rejected` |
| `below_threshold` | unprocessable | Payout amount below the `$100` threshold |
| `insufficient_balance` | unprocessable | Payout amount exceeds available balance |
| `exchange_not_processable` | unprocessable | We cannot route this specific swap. Don't retry — surface the response `message` verbatim. The error is **not** retryable. |
| `rate_limited` | rate_limit_error | Per-credential RPS exceeded — see `Retry-After` |
| `upstream_rate_limited` | rate_limit_error | GhostSwap's upstream liquidity cap hit |
| `upstream_not_configured` | upstream_error | Server-side configuration issue. Email support |
| `upstream_empty_quote` | upstream_error | No quote returned — usually means pair is unavailable |
| `upstream_bad_response` | upstream_error | Liquidity provider returned non-JSON. Retry your request with the same `Idempotency-Key` |
| `upstream_unreachable` | upstream_error | Network failure reaching the liquidity layer |
| `upstream_failed` | upstream_error | Generic liquidity-layer issue |
| `upstream_method_not_found` | upstream_error | Liquidity provider rejected the requested method |
| `provider_credential_pending` | upstream_error (503) | Your account is still being activated by GhostSwap. Fee-sensitive quotes and swap creation will be enabled once activation completes. No action needed by you |
## Recovery patterns
### Retry on transient
```js
async function retryable(fn) {
for (let i = 0; i < 3; i++) {
const res = await fn();
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
if (body?.error?.type !== 'upstream_error' && body?.error?.type !== 'rate_limit_error') {
throw new Error(`${body?.error?.code}: ${body?.error?.message}`);
}
const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** i;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
}
throw new Error('exhausted retries');
}
```
### Surface validation cleanly
```js
const body = await res.json();
if (body.error?.type === 'validation_error') {
showFieldError(body.error.param, body.error.message);
}
```
### Distinguish from upstream
```js
if (body.error?.type === 'upstream_error') {
// Show a generic "exchange provider is unavailable" message;
// your user didn't do anything wrong.
}
```
## See also
- [Idempotency](/docs/concepts/idempotency) — how to retry safely.
- [Rate limits](/docs/concepts/rate-limits) — pre-empting 429s.
---
## Page: /docs/guides/end-to-end-swap
URL: https://partners.ghostswap.io/docs/guides/end-to-end-swap
# 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](/docs/auth).
- 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: 10s visible, 30s background) ──> [ 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
```js
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 should 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',
actualAmountFrom: final.amountActualFrom,
actualAmountTo: final.amountActualTo,
payinHash: final.payinHash,
payoutHash: final.payoutHash,
});
} 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 **support@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 account is still being activated by GhostSwap | Fee-sensitive quotes and swap creation enable once activation lands |
| `upstream_error` `upstream_bad_response` (HTTP 502) | Liquidity provider returned a non-JSON response | On `POST /v1/swaps`, **do not auto-retry** — call `GET /v1/swaps?limit=20` and check whether a swap with your `partnerReferenceId` is already present. Full procedure: [Troubleshooting](/docs/guides/troubleshooting). |
## See also
- [Idempotency](/docs/concepts/idempotency) — retry semantics in depth.
- [Status lifecycle](/docs/concepts/status-lifecycle) — every status with partner UX guidance.
- [Errors](/docs/concepts/errors) — full error type matrix.
- [Security](/docs/guides/security) — keep the credential server-side.
---
## Page: /docs/guides/troubleshooting
URL: https://partners.ghostswap.io/docs/guides/troubleshooting
# Troubleshooting
This page covers concrete debugging strategies for the integration paths that aren't always obvious. If you hit something not listed here, email [support@ghostswap.io](mailto:support@ghostswap.io) with the response `X-Request-Id` header.
## "Invalid pair: x-y not available or temporary disabled" on swap creation
You'll see this as:
```json
{ "error": { "type": "validation_error", "code": "invalid_request",
"message": "Invalid pair: btc-eth not available or temporary disabled",
"upstream_code": -32602 } }
```
**The literal text isn't always the literal cause.** This message wraps several different root causes — the underlying error code (`-32602`) is "invalid params" at the JSON-RPC layer, and the platform sometimes serves this generic message even when the real issue is something else.
Most common actual causes (in order of likelihood):
1. **The `address` or `refundAddress` failed the strict creation-time validator.** Even though `POST /v1/addresses/validate` accepted the address, the swap-creation path runs a stricter chain-specific validator. Try a different address format:
- BTC: try a legacy `1…`/`3…` address instead of a bech32 `bc1…` (or vice versa)
- ETH: try a checksummed mixed-case address (`0xAbCd…`) instead of all-lowercase
2. **`amountFrom` has too many decimal places** for the source chain's precision. Try fewer decimals (e.g. `0.001` instead of `0.00100012`).
3. **Genuine temporary disable** of the pair. Rare for major pairs (BTC↔ETH, ETH↔USDT, etc.). Try a different pair to rule out a global account issue.
### Debug recipe — strip the swap to its minimum
```js
// Try with NO refundAddress to isolate which field is rejected.
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
from: 'btc', to: 'eth',
amountFrom: '0.001', // round number, well above min
address: '0xKnownGoodEthAddr', // an ETH address you control
// NO refundAddress
}),
});
```
If this succeeds → the refund address was the issue. Try a different format.
If this fails with the same error → it's the payout address or the amount.
## `/v1/addresses/validate` accepts but `/v1/swaps` rejects the same address
Known behavior. The two validators are different:
- `POST /v1/addresses/validate` runs a permissive chain-format check — basically "is this a syntactically valid address for this chain?"
- `POST /v1/swaps` runs a stricter check internally that rejects addresses that *parse* but won't actually work for the on-chain payout (e.g. addresses with the wrong checksum, or for the wrong network within a chain family).
Don't treat `/v1/addresses/validate` as proof the swap will succeed. Treat it as inline UX feedback during typing — fast and lenient. The real verdict comes at `POST /v1/swaps`.
## Verifying a swap was actually created
When `POST /v1/swaps` returns HTTP 201 with a swap object, **the swap is real**. Your own DB or dashboard may lag — the source of truth is `GET /v1/swaps/:id`:
```bash
curl https://partners-api.ghostswap.io/v1/swaps/htpi6bqnazl7hbjd \
-H "Authorization: Bearer $TOKEN"
```
If it returns the swap row, the swap exists in our system and the upstream liquidity layer.
For the list view of your org's swaps:
```bash
curl https://partners-api.ghostswap.io/v1/swaps?limit=20 \
-H "Authorization: Bearer $TOKEN"
```
## Where do I see the dashboard for my swaps?
The GhostSwap partner dashboard at [/dashboard/transactions](/dashboard/transactions) shows every swap created with credentials owned by your organization. Auto-refreshes every 10 seconds; flashes rows whose status changed.
Your partner-fee percent is locked at application time and is already factored into the `amountUserReceives` field returned by `POST /v1/quotes`. You don't need to add it on top — what the quote shows is what your end-user receives.
## "I'm getting `503 upstream_not_configured`"
Server-side: a liquidity-key configuration issue we need to resolve on our end. **Email support@ghostswap.io with the `X-Request-Id`** — there's no client-side workaround.
In the meantime, you can build the rest of your integration (UI, polling logic, error handling) against the read endpoints (`GET /v1/currencies`, `GET /v1/pairs`, `POST /v1/addresses/validate`) — those work independent of liquidity-key activation.
## "I'm getting `503 provider_credential_pending`"
Your account is still being activated by GhostSwap. The error message is:
> *Your account is still being activated. Currencies, pairs, and address validation are available now; fee-sensitive quotes and swap creation will be enabled within 1-3 business days.*
This means:
- Currency, pair, and address-validation endpoints remain useful while you build
- `POST /v1/quotes` and `POST /v1/swaps` are blocked until activation finishes
- Typical wait: **1-3 business days** after admin approval
What to do:
- Build the rest of your integration (UI, polling logic, error handling) using currencies, pairs, and address validation while fee-sensitive quotes/swaps wait for activation
- Watch your dashboard at `/dashboard/api-credentials` — when the activation lands, the next `/v1/swaps` POST will succeed
If it's been more than 24 hours since approval, email `support@ghostswap.io` (or ping us on [Telegram](https://t.me/ghostswap1)) with your `X-Request-Id`.
## "I'm getting `502 upstream_bad_response`"
The liquidity provider returned a non-JSON response — usually a transient CDN hiccup at their edge.
**On read endpoints** (`/v1/currencies`, `/v1/pairs`, `/v1/quotes`, `GET /v1/swaps/:id`): safe to retry once after a short backoff. These are stateless on our side.
**On `POST /v1/swaps`**: do **not** auto-retry blind. To prevent the rare case where the upstream may have accepted the swap before failing to return a parseable response, we fail the call immediately and do not retry internally on this path. Your retry-with-the-same-`Idempotency-Key` would hit our cache miss and could create a second upstream swap that we don't yet have a row for. Instead:
1. Call `GET /v1/swaps?limit=20` and check whether a swap with your `partnerReferenceId` is already present.
2. If yes, treat that swap as the canonical one — display its `payinAddress` to the user.
3. If no swap exists after ~30 seconds, retry with a **new** `Idempotency-Key`.
4. If the problem persists or you're unsure, email support with your `X-Request-Id` before retrying.
## Idempotency key — when does it actually save you?
`Idempotency-Key` (UUID v4 on `POST /v1/swaps`) protects against duplicate swap creation in three concrete situations:
1. **Network retry**: your fetch fails with a transient error; you retry with the same key — get the same swap back, no duplicate.
2. **Concurrent click**: user clicks "Confirm" twice in quick succession (e.g., laggy UI). If both requests carry the same key, both return the same swap.
3. **Process crash mid-call**: your service crashes between sending the request and storing the response. On restart, you can replay with the same key and recover.
The key must be **the same across retries of the same logical attempt**. Generate it once per "Confirm click" and store it before sending. Don't generate a new UUID inside the retry loop.
If you reuse the same `Idempotency-Key` with a **different request body**, you get HTTP 409 `conflict` — that's a guard against accidental key reuse with mismatched data.
## 429 rate-limited — retry strategy
```
HTTP/1.1 429 Too Many Requests
Retry-After: 1
```
Read `Retry-After` (seconds) from the response header. Wait that long, then retry with the same `Idempotency-Key` (if it was a swap creation). Two limits apply — 120 RPS per source IP and 30 RPS per credential — and separate credentials never share a quota.
If you see 429 on read endpoints (`/v1/currencies`, etc.), back off. These calls are cheap to cache (5–10 min for currencies).
## "I lost my secret"
You have two paths:
**You think the secret is just lost (not leaked)** — visit `/dashboard/api-credentials`, find the credential row, click **Reveal secret**. The secret is shown again next to the always-visible public key. Copy both, store securely, click Hide.
**You think the secret may have leaked (committed to a repo, posted in chat, etc.)** — **revoke** instead. Click **Revoke** on the credential row → it's invalidated everywhere within seconds. Then click **Create live credential** for a new pair.
If a credential was created **before recovery was supported** (early-stage credentials), Reveal returns *"This credential was created before secret recovery was enabled. Revoke it and create a new one to get a recoverable secret."* — follow the revoke + reissue path.
Every Reveal action is audit-logged on our side.
## Common confusion: `amountTo` vs `amountUserReceives`
Always show `amountUserReceives` to the user — it equals `amountTo - networkFee` and is the realistic estimate. Showing raw `amountTo` overstates the user's payout by the network fee.
## Currency icons that 404
Some currencies have `image: null` in the response. Always wrap your icon render in a fallback:
```jsx
{currency.image
? e.target.style.display = 'none'} />
: {currency.ticker[0].toUpperCase()}}
```
## See also
- [Errors](/docs/concepts/errors) — full error type matrix
- [Idempotency](/docs/concepts/idempotency) — semantics and retry patterns
- [Status lifecycle](/docs/concepts/status-lifecycle) — every state transition
- [End-to-end swap guide](/docs/guides/end-to-end-swap) — happy-path implementation
---
## Page: /docs/guides/security
URL: https://partners.ghostswap.io/docs/guides/security
# Security
GhostSwap credentials let the holder create swaps that move real funds. Treat them like a payment processor secret key.
## Keep credentials server-side
✅ **Server-side environment variables** loaded into your runtime.
✅ **Secret managers** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect).
✅ **Backend services with TLS 1.2+** to call our API.
❌ **Browser code** — single-page apps, mobile apps, anything served to a user device. Anything in the client bundle can be extracted in seconds.
❌ **URL query strings** — leak into server logs, browser history, referrer headers, third-party analytics.
❌ **Source control** — even private repos. Use `.env` (gitignored) or a secret manager.
❌ **Error messages, logs, response bodies you return to the user.**
## Protect the secret at rest
The secret half (`gssk_live_*`) is shown at creation and is **re-viewable** from the dashboard via the **Reveal secret** button. Treat it as a production secret regardless.
Where to store it depends on the environment:
```bash
# Local development — fine when the file is gitignored:
echo "GHOSTSWAP_SECRET=gssk_live_..." >> .env.local
# Production — use a real secret manager so the value is never on disk
# beside your code, never baked into a build artifact, and rotatable
# without a redeploy:
gh secret set GHOSTSWAP_SECRET # GitHub Actions
op item edit "GhostSwap" credential=... # 1Password
aws secretsmanager put-secret-value --secret-id ghostswap/prod ... # AWS
doppler secrets set GHOSTSWAP_SECRET=... # Doppler
```
The cardinal rule: the secret never lives in source control, build artifacts, browser bundles, log lines, or URL query strings.
If you lose track of the secret, you can recover it from `/dashboard/api-credentials` (Reveal secret on the credential row). Every reveal is audit-logged on our side; we will reach out if we see a pattern that looks unusual (multiple reveals from new IPs, etc.).
> **Reveal is for recovery, not a routine flow.** Step-up auth (re-prompt for password / WebAuthn) and a rate cap on reveals are on the near-term roadmap. In the meantime: if you suspect a secret has leaked, **revoke** the credential instead of revealing — that invalidates it everywhere immediately and lets you issue a fresh credential after the brief 401 window.
How we store it: the secret lives as both an `argon2id` hash (used on every authentication) and an `AES-256-GCM` ciphertext (used for the Reveal flow). Both require a master encryption key held only on our infrastructure to decrypt — neither can be recovered from a database snapshot alone.
## Rotate regularly
Recommended cadence: every 90 days, plus on any of:
- Team member who had access leaves.
- Suspected secret leak (committed to repo, posted in chat, etc.).
- Unexplained traffic spike on the credential's last-used metric.
Rotation flow:
1. Create a new credential in the dashboard.
2. Roll the new public key + secret into your environment.
3. Verify traffic flows through the new credential (watch its `last_used_at`).
4. Revoke the old credential.
## Reduce blast radius
- **Separate credentials per environment**: one for staging, one for production. (Built-in test-mode keys are on the [roadmap](/docs/roadmap); until then, use a second live credential as your staging key and keep the swap amounts small.)
- **Don't share credentials between services.** If service A and service B both need access, give them separate credentials so you can rotate one without touching the other.
- **Audit credential usage.** The dashboard shows `last_used_at`. Anything stale is a candidate for revocation.
## Monitor traffic
- **Log every request's `X-Request-Id` response header.** When escalating to support, this lets us find your exact request.
- **Alert on unexpected 401s.** Could indicate a revoked or rotated credential; could also indicate a compromised credential being abused.
- **Alert on unusual rate-limit errors.** A sudden spike in 429s without a corresponding traffic increase suggests someone else has your credential.
- **Track per-credential volume against expectations.** If a credential normally creates 100 swaps/day and you see 10,000, that's an alert.
## TLS only
`https://partners-api.ghostswap.io` only. We do not serve over plain HTTP. Reject anything that comes back unencrypted.
## What we do server-side
For your awareness, here's what we do to protect you:
- Secrets are stored as **argon2id hashes**. Nobody at GhostSwap can read your secret — even our DBAs see only the hash.
- Bearer tokens are checked in constant time to prevent timing attacks.
- Per-credential and global rate limits cap the damage from abuse.
- Every request is logged with `X-Request-Id` for forensics.
- Idempotency keys prevent replay attacks from creating duplicate swaps.
## Reporting a leak
If you suspect your credential has leaked:
1. **Revoke immediately** at [/dashboard/api-credentials](https://partners.ghostswap.io/dashboard/api-credentials).
2. Email **support@ghostswap.io** with the credential's public key (`gspk_live_...`) and a description of the suspected exposure.
3. We'll review usage logs and respond within one business day.
## What's coming
[Roadmap](/docs/roadmap) entries that further harden credentials:
- Per-credential **IP allowlists** so a leaked credential can't be used outside your servers.
- Per-credential **method scopes** for read-only or restricted credentials.
- Test-mode keys (`gssk_test_*`) so you can iterate safely without live funds.
---
## Page: /docs/roadmap
URL: https://partners.ghostswap.io/docs/roadmap
# Roadmap
The GhostSwap Partners API surface today and what's queued for v2. We optimize for shipping a tight v1 surface that works end-to-end before broadening.
## What's live today
| Feature | Surface |
|---|---|
| Float-mode crypto-to-crypto swaps | `POST /v1/swaps` |
| Fixed-rate swaps — lock the rate at quote time | `POST /v1/quotes` + `POST /v1/swaps` with `mode: "fixed"` |
| Listing supported currencies (with metadata) | `GET /v1/currencies` |
| Per-pair min/max | `GET /v1/pairs?from=&to=` |
| Address syntax validation | `POST /v1/addresses/validate` |
| Quotes with `amountUserReceives` helper | `POST /v1/quotes` |
| Idempotency on swap creation | `Idempotency-Key` header, 24h cache |
| Rate limiting | 120 RPS per source IP and 30 RPS per credential enforced; RFC-9112 `RateLimit-*` headers on every response |
| Status polling | `GET /v1/swaps/:id` |
| Org-scoped swap listing | `GET /v1/swaps` |
| Background status worker | Updates swap rows every 30s |
| Bearer auth | argon2id-hashed secrets, constant-time verification |
| Per-partner liquidity keys | Each partner gets their own upstream key with their fee config; routed automatically |
| Secret recovery | `gssk_live_*` re-viewable from the dashboard via **Reveal secret** (audit-logged) |
| Auto-retry on transient upstream | `upstream_bad_response` and `upstream_unreachable` retried once internally before surfacing |
| Per-swap commission ledger | USD-locked at swap-finish; visible on `/dashboard/earnings` |
## Coming next (v2)
| Feature | Why it's deferred | Workaround today |
|---|---|---|
| **List all enabled pairs** in one call | Per-pair lookup only at v1 | Query pairs individually with `from` and `to` |
| **`extraId` on swap creation** (XRP, XLM, EOS, IOST, STEEM, STX) | Adds destination-tag handling at every step of the lifecycle | These currencies are filtered from `/v1/currencies` until extraId support lands |
| **Outbound webhooks** to your server | Polling works; webhooks need signing, retries, dead-letter | Poll `GET /v1/swaps/:id` |
| **Test-mode keys** (`gssk_test_*`) | Single environment is simpler for v1; users dev with small live amounts | Use small amounts on live |
| **Per-credential IP allowlists** | Not yet on by default | Rotate credentials regularly |
| **Per-credential method scopes** | Read-only and restricted credentials | One credential = full access |
| **Last-used IP / user-agent in dashboard** | Telemetry only; doesn't affect functionality | Watch `last_used_at` timestamp |
| **CSV export of swaps** | Add when partners need it | `GET /v1/swaps?limit=100` and paginate |
| **Hosted widget** | Different product surface; API-first for v1 | Embed your own UI on top of the API |
| **Multi-language SDKs** (Node, Python, Go) | Code samples are sufficient at v1 scale | Use our [llms-full.txt](/llms-full.txt) and `fetch` |
## Found something we should prioritize?
We weight roadmap items by partner volume. If a feature is blocking you, email **support@ghostswap.io** with:
1. Your `org_public_id` from the dashboard.
2. The feature you want.
3. Your expected monthly swap volume that depends on it.
We move on the highest-volume blockers first.
---
_Generated from 17 pages._