# 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** — `
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/swp_abc123 \
-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".
### `apiExtraFee` may differ across pairs
The `apiExtraFee` in transaction records is set per-account by the platform operator and may vary by pair (e.g., 2.00% for stablecoins, 2.50% for native chains). This is operator config, not something you set in your request.
### 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.
### 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 30 seconds.** It doesn't give you fresher data; it just burns rate budget.
- ❌ **Don't send `mode: 'fixed'`.** v1 returns HTTP 400. Just 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 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 30 seconds (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. ☐ Have you checked that `mode: 'fixed'` does not appear anywhere in the code? It must NOT.
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 30s.
9. Reload the page (URL has `?swap=swp_…`) → swap state restored, polling resumes.
10. If the partner's per-org liquidity key isn't fully activated yet (1–3 business days after admin approval), `POST /v1/swaps` returns HTTP 503 with `error.code: provider_credential_pending` and a friendly message. Read endpoints (currencies, pairs, quotes, address validation) work normally during this window. Show the upstream message to the user verbatim — it's already partner-friendly.
11. The error code `upstream_bad_response` (HTTP 502) means the liquidity provider returned non-JSON; our backend already auto-retries once internally. If you see it bubble up, retry once more with the **same `Idempotency-Key`**.
## Reference
Live API base: `https://partners-api.ghostswap.io`
Full docs (this brief is the condensed form): `https://partners.ghostswap.io/docs`
Support: support@ghostswap.io
AML/KYC holds (user-facing): security@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.
- **Per-credential rate limits** — 30 RPS design target with `Retry-After` on 429s. Middleware is rolling out; see [Rate limits](/docs/concepts/rate-limits) for current enforcement status. We absorb upstream limits so your traffic stays smooth.
- **Real-time-ish status polling.** Background workers update swap status every 30s upstream; you poll us at any cadence.
- **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. We've already credited your commission ledger.
## Two ways to integrate
| Mode | Best for | Where it lives |
|---|---|---|
| **API** (this doc) | Wallets, exchanges, fintech apps that own the user UI | Your server |
| **Hosted widget** *(coming)* | Marketing sites, landing pages, no-code partners | iframe on your site |
The API is what's available today. Widgets are on the [roadmap](/docs/roadmap).
## Need help?
Email **support@ghostswap.io**. For AML/KYC holds on individual swaps, the user should email **security@ghostswap.io** directly — these are routed to our compliance team.
---
## 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 this page) — 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 the secret. The dashboard returns a pre-built `bearer_token` of the form `gspk_live_...:gssk_live_...` you can drop into your `Authorization` header. If you lose the plaintext 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 returns the pre-built `bearer_token` field at credential creation time so you can drop it in directly.
## 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 three values: your **public key**, your **secret**, and a pre-built **bearer token** (`:`).
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 + a fresh bearer token are shown again, with a Hide button to collapse
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 bearer token 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.
- 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` | Over 30 RPS for this credential |
| `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.
- For v1 you'll typically use the `*Float` fields. The `*Fixed` fields are returned for forward-compat — fixed-rate swap creation isn't enabled yet.
- 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` | Over 30 RPS |
| `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` | Over 30 RPS |
| `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
## Notes
- **Always show `amountUserReceives`** as the headline number. Showing raw `amountTo` over-promises by `networkFee`.
- Quotes are **indicative**, not locked. The final amount on `POST /v1/swaps` may differ slightly because we re-quote at swap creation time.
- For locked rates, fixed-rate mode is on the [roadmap](/docs/roadmap).
## 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 float-rate quotes. |
| `validation_error` (`field: 'mode'`) | `mode: 'fixed'` — not supported in v1. |
| `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 dedicated liquidity key is still being activated. Quotes 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 send swap.amountFrom there.
```
### Response (HTTP 201)
```json
{
"swap": {
"id": "swp_a1b2c3d4",
"providerSwapId": "fa1980b980bb7640",
"status": "waiting",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountExpectedTo": "0.1532",
"networkFee": "0.0021",
"rate": "15.32",
"payinAddress": "bc1qDepositAddressFromUs...",
"payoutAddress": "0xUserPayoutAddress...",
"refundAddress": "bc1qUserRefundAddress...",
"partnerReferenceId": "order_42",
"createdAt": "2026-04-29T12:00:00.000Z"
}
}
```
#### About the two IDs
Each swap response carries two identifiers — use the right one for the right job:
| Field | Format | Use it for |
|---|---|---|
| `id` | `swp_*` | All calls back into our API (`GET /v1/swaps/:id`, idempotency-key correlation, support escalation to `support@ghostswap.io`). Stable, partner-namespaced, always present. |
| `providerSwapId` | hex string from our upstream provider | Internal reference we use to correlate against upstream records. Include it when escalating a stuck swap to `support@ghostswap.io` so we can look it up faster. Briefly `null` between create and the first upstream confirm (typically under one second); steady-state always populated. |
If you store only one of the two on your end, store **both**. Saves you a lookup the day a partner asks "where is my transaction on the upstream side?"
---
## 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/swp_a1b2c3d4`, {
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": "swp_a1b2c3d4", "status": "finished", ... },
{ "id": "swp_99887766", "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 float-rate swaps. |
| `validation_error` (`field: 'mode'`) | `mode: 'fixed'` — not supported in v1. |
| `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. |
| `rate_limit_error` (`rate_limited`, HTTP 429) | Per-credential rate limit exceeded. Comes with `Retry-After`. See [Rate limits](/docs/concepts/rate-limits) for current enforcement status. |
| `upstream_error` (`provider_credential_pending`, HTTP 503) | Your dedicated liquidity key is still being activated upstream. Reads work; swap creation enables once activation completes (typically 1–3 business days from approval). See [Troubleshooting](/docs/guides/troubleshooting). |
| `upstream_error` (`upstream_bad_response`, HTTP 502) | Liquidity provider returned non-JSON. Auto-retried once internally; surface only if both attempts failed. Retry with the **same** `Idempotency-Key`. |
| `upstream_error` (other) | Generic liquidity-layer issue. Back off and retry with the same `Idempotency-Key`. |
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. Your fee is added on top of GhostSwap's flat 2% baseline. Together they form the total markup that's charged on each swap; the markup is split automatically and tracked per-swap in USD.
## The fee stack
```
User swaps 0.1 BTC → 3.31 ETH
Layer 1 — your fee (set at signup): 1.0% (example)
Layer 2 — GhostSwap baseline: 2.0% (always)
-----
Total markup user pays: 3.0%
```
Allowed range for **your fee**: **0.5% – 10%**. 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 with your total fee (`2% + your%`). The fee is set on that key by our liquidity team during onboarding and stays fixed for the life of the key. All swaps from your organization route through that key, so the upstream provider's per-key payout report is the settlement source-of-truth — no manual splitting on our side.
Provisioning typically completes within **1–3 business days** of approval. While it's in flight, swap creation returns a friendly `provider_credential_pending` (HTTP 503); read endpoints (currencies, pairs, quotes, address validation) work normally.
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. Fetches the live USD spot price for the source currency
3. Computes:
```
amount_from_usd = amountFrom × spot_price
your_commission = amount_from_usd × (your_fee_percent / 100)
```
4. Inserts an `estimated` row in your commission ledger
You see the new row appear on `/dashboard/earnings` within ~30 seconds of swap completion.
## Estimated → settled
Two-phase accounting:
| State | Meaning |
|---|---|
| `estimated` | Computed at swap-finish using live USD spot. Locked in your currency at that moment. |
| `settled` | The upstream paid GhostSwap for this swap; admin reconciled the per-key report. The `estimated` value matched within ±5%. |
| `voided` | Rare. Swap was charged-back or refunded after finishing. |
> **v1 status:** the `estimated → settled` flip currently runs as a **manual operations process** — a GhostSwap engineer reconciles the upstream per-key payout report against our ledger after each upstream settlement. A self-serve admin reconciliation upload is on the near-term roadmap. While we're manual, settlement cadence is weekly.
You're paid in USDT after your settled 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)
- `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 | amountFrom | spot price | volume USD | Your earning |
|---|---|---|---|---|---|
| `user_42` | btc → eth | 0.01 BTC | $65,000 | $650 | **$6.50** |
| `order_8731` | eth → usdt | 5 ETH | $3,300 | $16,500 | **$165.00** |
| `wallet_99` | usdt → btc | 1,000 USDT | $1.00 | $1,000 | **$10.00** |
Your `estimated` balance: **$181.50**. After the upstream settles (typically within 7 days), it flips to `settled` and becomes payable.
## 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 updating the upstream provider's apiExtraFee on your dedicated key — typically 1–3 business days. 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 |
| 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
> **v1 status:** rate limiting is being implemented. The 30 RPS per-credential
> limit described below is the design target — until the middleware ships,
> requests are not actively throttled. Build to the limit anyway: the moment
> we turn it on, partners exceeding 30 RPS will start receiving 429s. We'll
> announce a date in the changelog.
Two limits apply, in this order:
| Layer | Limit | Scope |
|---|---|---|
| Per-credential | **30 requests/second** | Each `gspk_live_*` credential separately |
| Global upstream | 10 requests/second | Aggregate across all GhostSwap partners (we absorb this) |
Once enforcement is live, hitting the per-credential limit returns HTTP 429 with a `Retry-After` header. The global cap is mostly invisible to you — we queue and serialize internally.
## Recognizing 429s
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json
X-Request-Id: 7b3c1e9f-...
{
"error": {
"type": "rate_limit_error",
"code": "rate_limited",
"message": "Per-credential 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
- **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 ~500ms.
- **Poll `GET /v1/swaps/:id` every 30 seconds** while a swap is non-terminal. There's no benefit to faster — our worker only updates the upstream every 30s.
- **Stop polling at terminal states.** `finished`, `failed`, `refunded`, `overdue`, `expired` — never poll these again.
## Need higher limits?
Email **support@ghostswap.io** with the credential id and your expected sustained RPS. We adjust the per-credential bucket 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:
- Read endpoints (currencies, pairs, address validation, quotes, status) are queued internally so partners don't see those 429s.
- Writes (swap creation) are serialized to fit under the cap.
If we ever need to surface a global 429 to you, the error type is `upstream_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 window expired before payin landed | "Window closed. Start a new swap." |
| `hold` | conditional | AML/KYC review — funds held until verified | Direct user to email **security@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 partner-facing UI is open. We absorb the upstream cost.
## 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` → `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 security@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. Code may be `unauthenticated` or `revoked_credential`. |
| `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 |
| `idempotency_key_mismatch` | conflict | Same `Idempotency-Key`, different body |
| `pending_request_exists` | conflict | A previous request of this kind is still in flight (e.g. payouts) |
| `unauthenticated` | authentication_error | Missing, malformed, or expired bearer credential |
| `revoked_credential` | authentication_error | Credential was revoked or its parent org was suspended |
| `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 |
| `rate_limited` | rate_limit_error | Per-credential RPS exceeded — see `Retry-After` |
| `upstream_rate_limited` | upstream_error | GhostSwap's upstream liquidity cap hit (rare; we shield against this) |
| `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. Auto-retried once internally before surfacing; if you see this, it survived the retry. Retry your request with the same `Idempotency-Key` |
| `upstream_unreachable` | upstream_error | Network failure reaching the liquidity layer. Auto-retried once internally |
| `provider_credential_pending` | upstream_error (503) | Your dedicated liquidity key is still being activated by our liquidity team (1–3 business days from approval). Reads work; 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 every 30s) ──> [ partners-api ]
│
▼
[ user UI ] (status updates)
```
Your server is the only thing that ever sees credentials. The user's browser only sees public data: deposit address, amount, status.
## Reference implementation
```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 must send ${amountFrom} ${from} to ${swap.payinAddress}.`);
// 4. Show payinAddress to the user. They send funds on chain.
await orderStore.update(orderId, {
swapId: swap.id,
payinAddress: swap.payinAddress,
expectedPayout: swap.amountExpectedTo,
});
// 5. Poll until terminal. In production, do this in a background job, not
// inline — this could take hours.
const final = await pollUntilTerminal(swap.id, {
onUpdate: (s) => orderStore.update(orderId, { status: s.status }),
});
// 6. Credit the user (or refund flow) based on terminal status.
if (final.status === 'finished') {
await orderStore.update(orderId, { state: 'fulfilled' });
} else {
await orderStore.update(orderId, { state: 'failed', failureReason: final.status });
}
return final;
}
```
## Production hardening
A few things the example glosses over:
- **Run polling out-of-process.** Spin up a worker (BullMQ, Sidekiq, Cloud Tasks) that polls and updates your DB. The HTTP request that creates the swap should return as soon as you have `payinAddress`; don't make the user wait through the polling loop.
- **Persist the idempotency key BEFORE the API call.** If your process crashes between the API call and the response handler, the next run can recover with the same key.
- **Log `X-Request-Id`** from every response. When something goes wrong and you escalate to support, this lets us find the exact request in our logs.
- **Validate amounts client-side** against `GET /v1/pairs?from=&to=` before submitting. Surface "minimum is X" errors before the user is committed.
- **Show the user a copy button** for `payinAddress`. Display a QR code if you can. Fat-fingered addresses are the #1 cause of failed swaps.
- **Handle `hold` carefully.** Tell the user "review in progress; check your email for instructions" — don't promise resolution times. Direct them to **security@ghostswap.io** if they reach out.
## Common failure patterns
| What you see | Likely cause | Fix |
|---|---|---|
| `validation_error` `amount_below_min` at quote time | User entered too small a value | Display the min from the quote response |
| `validation_error` `invalid_address` at swap creation | The user's `payoutAddress` is malformed for the target chain | Validate address before showing the Confirm button |
| Swap stuck in `waiting` for 36+ hours | User never sent funds | Eventually flips to `overdue`. Stop polling |
| Swap goes `confirming` → `failed` | Deposited amount under minimum | Refund flow follows automatically; status will go to `refunded` |
| `upstream_error` `upstream_not_configured` | Server-side configuration issue | Email support@ghostswap.io with the `X-Request-Id` |
| `upstream_error` `provider_credential_pending` (HTTP 503) | Your dedicated liquidity key is still being activated by our team (1–3 business days from approval) | Build against read endpoints in the meantime; swap creation enables once activation lands |
| `upstream_error` `upstream_bad_response` (HTTP 502) | Liquidity provider returned non-JSON; auto-retried once internally and still failed | Retry once with the **same `Idempotency-Key`** |
## See also
- [Idempotency](/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.
## `validateAddress` accepts but `createTransaction` rejects
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 `validateAddress` 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/swp_abc123 \
-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.
`apiExtraFee` (visible on swap records) is platform-operator config, not partner-controllable. It's the percent the platform charges on top of the upstream cost — already factored into `amountUserReceives` in quotes.
## "I'm getting `503 upstream_not_configured`"
Server-side: the platform-shared upstream liquidity key isn't wired up. **Email support@ghostswap.io with the `X-Request-Id`** — this is operator action, not something you can fix.
In the meantime, you can develop against a stub. The [LLM brief](/llms-full.txt) shows a `MOCK_GHOSTSWAP=1` pattern: `lib/ghostswap.js` short-circuits `gsFetch` to canned responses when the env var is set.
## "I'm getting `503 provider_credential_pending`"
Your dedicated liquidity key is still being activated by our liquidity team. The error message is:
> *Your liquidity key is being activated. Reads (currencies, pairs, quotes, address validation) work; swap creation will be enabled within 1–3 business days.*
This means:
- Read endpoints (currencies, pairs, quotes, address validation) work right now
- `POST /v1/swaps` is blocked until the 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 the read endpoints
- Use `MOCK_GHOSTSWAP=1` against a stub to test swap creation flows end-to-end
- Watch your dashboard at `/dashboard/api-credentials` — when the activation lands, the next `/v1/swaps` POST will succeed
If it's been more than 3 business days, email `support@ghostswap.io` with your `X-Request-Id`.
## "I'm getting `502 upstream_bad_response` after a retry"
Our backend already retries this once internally before surfacing it. If you see it on your side, both attempts hit a non-JSON response from the liquidity provider's edge — almost always a transient Cloudflare/CDN hiccup.
**Fix**: retry once with the **same `Idempotency-Key`**. If it persists for more than a few minutes, email support with your `X-Request-Id`.
## 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). The upstream rate limit is per-credential — separate credentials don't 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 + a fresh bearer token are shown again. Copy them, 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 the master `KEY_ENCRYPTION_KEY` env var 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 bearer token 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. (When per-environment keys land — see [Roadmap](/docs/roadmap) — this becomes a built-in feature.)
- **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` |
| 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 |
| Per-credential rate limiting | 30 RPS with `Retry-After` |
| 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 |
|---|---|---|
| **Fixed-rate swaps** (`mode: 'fixed'`) | Locks rate for ~60s; needs new lifecycle handling | Use float-rate. Quote close to swap creation to minimize drift |
| **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._