| name | ghostswap-partners-api |
| description | Integrate the GhostSwap Partners API to add no-KYC crypto swaps (1,600+ coins, 0–4% partner fee on top of the swap, USDT payouts) into wallets, dApps, exchanges, payment flows, or affiliate sites. Use whenever a developer asks to "add crypto swap", "embed a swap widget", "let users exchange tokens", "convert crypto to crypto", "earn commission on referred swaps", or wants a server-side REST integration that handles quote → create → status-poll without holding signing keys. Covers Bearer auth, live float and fixed-rate quotes, idempotent swap creation, status lifecycle polling, address validation, error envelope handling, rate limits, and the partner application + payout flow at partners.ghostswap.io. |
| license | MIT |
GhostSwap Partners API integration
You are helping a developer integrate the GhostSwap Partners API — a server-to-server REST API for non-custodial crypto-to-crypto swaps. The developer's end-users send funds to a deposit address GhostSwap returns; the destination wallet receives the swapped funds. GhostSwap handles all upstream liquidity, signing, and clearing. The partner earns a 0–4 % markup (chosen at application time) on every completed swap, paid out in USDT once balance ≥ $100.
This skill is the single document you need to produce a working end-to-end integration. Read it once, then write code.
When to use this skill
Trigger when the developer's request involves any of:
- Adding a crypto exchange widget, swap form, or "convert X to Y" flow to their app
- Accepting one crypto and crediting the user in a different one
- Quoting live exchange rates for a pair (e.g. BTC → ETH)
- Earning affiliate commission on referred swaps (the
?ref=<slug> flow on ghostswap.io)
- Building a Telegram bot, Discord bot, or wallet plugin that does crypto-to-crypto
- Specifically mentions "GhostSwap", "ghostswap.io", "partners.ghostswap.io",
gspk_live_*, or gssk_live_*
Do not trigger for fiat on/off-ramps, custodial trading APIs, or self-custodial DEX SDKs — those are different products.
Architecture, in one sentence
Your server holds the bearer token; your server proxies every call to https://partners-api.ghostswap.io; your browser code talks only to your server.
The bearer must never reach the browser. Browser code calls /api/quote, /api/swap, etc. on the developer's own server; the server adds Authorization: Bearer ${PUBLIC_KEY}:${SECRET} and forwards to GhostSwap.
Step 0 — Get a credential (one-time, by the developer)
The developer (not the agent) must do this once:
- Sign in at https://partners.ghostswap.io/sign-up.
- Submit the application form (org name, website, expected monthly volume, chosen partner fee 0–4 %).
- Wait for admin approval (~24 h). Once status flips to
active, an activation email lands in their inbox.
- Open /dashboard/api-credentials → click Create live credential → copy both values:
GHOSTSWAP_PUBLIC_KEY — looks like gspk_live_<32 hex>, always recoverable from the dashboard.
GHOSTSWAP_SECRET — looks like gssk_live_<48 hex>, shown once at creation; can be revealed later via the audit-logged Reveal secret button.
- Set both as environment variables on the developer's server. Never paste them into code that ships to the browser.
If the developer says "I haven't applied yet", direct them to that URL and pause the integration work — no API call works without a credential.
The standard workflow (memorize this)
1. GET /v1/currencies → populate dropdowns
2. POST /v1/addresses/validate → on blur, check the user's payout address
3. POST /v1/quotes → live quote on every amount/pair change
4. POST /v1/swaps → on confirm; MUST include Idempotency-Key
5. GET /v1/swaps/{id} → poll every 10 s while UI visible, 30 s when backgrounded
6. Stop polling on terminal status → finished | failed | refunded | overdue | expired
That's the entire flow. Every well-built integration is some variant of these six steps.
Authentication
Every /v1/* request needs exactly two headers:
Authorization: Bearer ${GHOSTSWAP_PUBLIC_KEY}:${GHOSTSWAP_SECRET}
Content-Type: application/json
The colon between public key and secret is part of the bearer string — there is no separate signing step. The server argon2id-verifies the secret against a hashed copy; partners never sign anything themselves.
const AUTH = `Bearer ${process.env.GHOSTSWAP_PUBLIC_KEY}:${process.env.GHOSTSWAP_SECRET}`;
Endpoint cheat sheet
Base URL: https://partners-api.ghostswap.io
| Method | Path | Purpose | Notes |
|---|
| GET | /v1/currencies | List enabled coins | ?lite=true returns just tickers |
| GET | /v1/pairs?from=btc&to=eth | Min/max for a pair | Use minAmountFloat/maxAmountFloat for float, …Fixed for fixed-rate |
| POST | /v1/addresses/validate | Check a wallet address | { "currency": "eth", "address": "0x…" } |
| POST | /v1/quotes | Get a live quote | { from, to, amountFrom }. Add mode: "fixed" for a locked rate |
| POST | /v1/swaps | Create a swap | Requires Idempotency-Key header. Returns payinAddress + id |
| GET | /v1/swaps/{id} | Get current status | Source of truth — poll this |
| GET | /v1/swaps?limit=50&offset=0 | Paginated list | Scoped to the org, most-recent first |
| GET | /health | Liveness probe | Unmetered; safe at any frequency |
Display rules (these are the user-visible parts)
Quote response shape:
{
"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"
}
}
Show amountUserReceives to the user, not amountTo. The subtraction (amountTo - networkFee) is already done. Also display rate, min, and max. Re-render on every change of from, to, or amountFrom — debounce ~400 ms so the user can type freely.
Swap response shape:
{
"swap": {
"id": "htpi6bqnazl7hbjd",
"status": "waiting",
"from": "btc", "to": "eth",
"amountFrom": "0.01",
"amountExpectedTo": "0.1532",
"payinAddress": "bc1q…",
"payoutAddress": "0x…",
"refundAddress": "bc1q…",
"createdAt": "2026-04-29T12:00:00Z"
}
}
Display payinAddress prominently — that's the deposit address the user must send amountFrom of from to. Render as a copyable string; a QR code is nice-to-have (use qrcode from npm).
The id is the canonical swap identifier in your records, GhostSwap's records, and the upstream provider's records. Use it for every follow-up call and store it on your order row.
Idempotency — non-negotiable rule
POST /v1/swaps requires an Idempotency-Key header. Generate a UUID v4 once per logical "Confirm" click and reuse it across HTTP retries of THAT click. Do not generate a fresh UUID on retry — that creates a duplicate swap.
import { randomUUID } from 'node:crypto';
let confirmKey = null;
function onConfirmClick() {
if (!confirmKey) confirmKey = crypto.randomUUID();
}
await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': req.body.idempotencyKey,
},
body: JSON.stringify(swapBody),
});
If the developer's code regenerates the UUID on every fetch, fix it. That single bug has produced duplicate swaps for real partners.
Polling cadence
Poll GET /v1/swaps/{id} after creation:
| UI state | Interval |
|---|
| Browser tab visible, status non-terminal | 10 seconds |
| Browser tab backgrounded or server-side worker | 30 seconds |
Status hold (AML/KYC review) | 5 minutes — direct user to support@ghostswap.io |
| Status terminal | Stop polling |
Terminal statuses: finished, failed, refunded, overdue, expired.
GhostSwap refreshes upstream status every ~30 s, so polling faster than 10 s wastes the rate budget without giving the user fresher data. Polling slower than 60 s feels laggy while the user is watching the page.
Float vs fixed-rate swaps
| Float (default) | Fixed |
|---|
| Rate determined | At swap-finish time (whatever the market gives) | Locked at quote time |
| Body field | omit mode | mode: "fixed" + rateId from quote |
refundAddress | optional but recommended | required |
| Time pressure on user | minutes (waiting window) | seconds (~60 s payTill window) |
| Best for | Most integrations | Receipts/invoices that need a guaranteed output |
Default to float unless the product specifically needs a locked rate. The fixed flow adds two failure modes: stale rateId (HTTP 409 rate_expired → re-quote with a new Idempotency-Key) and expired status (the user missed the payTill deadline).
Error envelope
Every non-2xx body has this shape:
{
"error": {
"type": "validation_error",
"code": "amount_below_min",
"message": "Minimum amountFrom is 0.0008",
"param": "amountFrom"
}
}
Surface error.message to the end-user (and error.param when present). Recovery by type:
type | HTTP | What to do |
|---|
validation_error | 400 | Show the message; user can fix |
authentication_error | 401 | Bad bearer — check env vars on the server |
authorization_error | 403 | Org not yet active — wait for admin approval |
not_found | 404 | Wrong id, or id belongs to another org |
conflict | 409 | Idempotency-Key reused with a different body, or fixed rateId expired — re-quote and retry with a new key |
unprocessable (code exchange_not_processable) | 422 | We can't route this specific swap. Do not retry. Surface message verbatim |
rate_limit_error | 429 | Read Retry-After (seconds), sleep, retry. If retrying POST /v1/swaps, reuse the same key |
upstream_error | 502 / 503 | Transient. Backoff and retry once. On POST /v1/swaps, first call GET /v1/swaps?limit=20 to check whether the swap was created before retrying |
Every response includes an X-Request-Id header. Log it. Forward it to support when escalating.
Rate limits
- Per credential: 30 RPS — every
gspk_live_* gets its own bucket.
- Per source IP: 120 RPS (pre-auth) — generous for partners on shared egress.
- Standard
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers on every response. Self-throttle from RateLimit-Remaining before hitting 429.
/health is unmetered.
Minimal working example (Node.js + Express)
This is a complete server-side proxy you can deliver to the developer as a starting point. ~120 lines, no build step, no frameworks beyond Express.
import 'dotenv/config';
import express from 'express';
import { randomUUID } from 'node:crypto';
const { GHOSTSWAP_PUBLIC_KEY, GHOSTSWAP_SECRET, PORT = 3000 } = process.env;
if (!GHOSTSWAP_PUBLIC_KEY || !GHOSTSWAP_SECRET) {
console.error('Set GHOSTSWAP_PUBLIC_KEY and GHOSTSWAP_SECRET in your env.');
process.exit(1);
}
const BASE = 'https://partners-api.ghostswap.io';
const AUTH = `Bearer ${GHOSTSWAP_PUBLIC_KEY}:${GHOSTSWAP_SECRET}`;
async function gs(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,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = data?.error ?? { message: `HTTP ${res.status}` };
const e = new Error(err.message);
Object.assign(e, { status: res.status, ...err });
e.retryAfter = res.headers.get('Retry-After');
e.requestId = res.headers.get('X-Request-Id');
throw e;
}
return data;
}
const app = express();
app.use(express.json({ limit: '10kb' }));
const route = (fn) => async (req, res) => {
try { res.json(await fn(req)); }
catch (e) {
if (e.retryAfter) res.setHeader('Retry-After', e.retryAfter);
res.status(e.status || 502).json({
error: { type: e.type, code: e.code, message: e.message, param: e.param },
});
}
};
app.get('/api/currencies', route(() => gs('GET', '/v1/currencies')));
app.post('/api/quote', route((r) => gs('POST', '/v1/quotes', { body: r.body })));
app.post('/api/validate', route((r) => gs('POST', '/v1/addresses/validate', { body: r.body })));
app.post('/api/swap', route((r) => {
const { idempotencyKey, ...body } = r.body;
if (!idempotencyKey) {
const e = new Error('idempotencyKey is required');
e.status = 400; e.code = 'missing_idempotency_key'; e.type = 'validation_error';
throw e;
}
return gs('POST', '/v1/swaps', { body, idempotencyKey });
}));
app.get('/api/swap/:id', route((r) => gs('GET', `/v1/swaps/${encodeURIComponent(r.params.id)}`)));
app.listen(PORT, () => console.log(`http://localhost:${PORT}`));
The matching browser code reads crypto.randomUUID() on first Confirm click, stores it in a variable, sends it to /api/swap, and resets it only after success. See the full reference implementation (HTML + browser JS, ~200 lines) in the end-to-end guide.
Critical rules — never violate
- Server-side only. The bearer must never reach the browser. All browser → API calls go through the developer's own server.
- Bearer format is
${PUBLIC_KEY}:${SECRET} with a single colon. Both values from env vars.
Idempotency-Key is required on POST /v1/swaps. UUID v4, one per Confirm click, reused on retries.
- Display
amountUserReceives, not amountTo. The network-fee subtraction is already done for you.
- Refund address is optional on float, required on fixed. Send
undefined (or omit the key) when the user can't provide one — never send an empty string.
- Stop polling on terminal status. Don't keep hitting
/v1/swaps/{id} forever.
- On 429, read
Retry-After and sleep. Don't hammer.
- On
upstream_error (5xx) after POST /v1/swaps, do not auto-retry. First call GET /v1/swaps?limit=20 and look for your partnerReferenceId — the swap may already exist.
- Disable the Confirm button until a quote has loaded AND the payout address validated. Eager-enabled buttons let users submit blindly.
- Never put the secret in browser code, in git, or in client-side env files. Server env vars or a secret manager only.
Anti-patterns to refuse
If the developer asks for any of these, push back and explain why:
- ❌ "Put the API key in the React app for simplicity" — leaks credentials in page source. The fix is a server proxy.
- ❌ "Generate a new idempotency key on each retry" — creates duplicate swaps. Reuse the same UUID for the same logical click.
- ❌ "Skip the quote panel; users just want to confirm" — biggest failure mode of bad integrations. Users have no idea how much they'll receive. Show
amountUserReceives before Confirm enables.
- ❌ "Poll every second so it feels real-time" — wastes the 30-RPS budget and gives no fresher data (upstream refresh is ~30 s). 10 s while visible is the right number.
- ❌ "Send
extraId on swap creation" — currencies needing memos (XRP, XLM, EOS, etc.) are filtered out at the source. Don't add this field.
- ❌ "Catch errors silently" — always surface
error.message and error.param. Silent failures lose users.
- ❌ "Hard-code the base URL" — read
GHOSTSWAP_API_BASE from env so staging can point elsewhere.
Affiliate referral links (optional, no integration code)
Every approved partner also gets a referral link: https://ghostswap.io/?ref=<their-slug>. End-users who click that URL get a 30-day cookie attribution; any swap they complete on ghostswap.io credits the partner 1 % of the volume. No code on the partner's end — they just share the URL. The partner claims their slug from the dashboard overview page.
The partner's API-integration earnings and referral earnings combine into one payable USDT balance, paid out once balance ≥ $100.
If the developer's primary use case is "I just want a share link, not an integration" — they don't need any of this skill's code. Direct them to https://partners.ghostswap.io/sign-up, tell them to apply, and after approval the Pick your referral code card on /dashboard handles everything else. No more steps.
Pre-flight checklist before declaring done
Before handing the integration back to the developer, walk through this:
- ☐
GHOSTSWAP_PUBLIC_KEY and GHOSTSWAP_SECRET are server-side env vars, not in browser code or git.
- ☐ All
/v1/* calls go through the developer's own server (not directly from browser).
- ☐
Idempotency-Key is a UUID generated once per Confirm click, reused on retries.
- ☐ The UI shows
amountUserReceives, rate, min, max before the Confirm button is enabled.
- ☐ Confirm button is disabled until (a) a quote has loaded, (b) payout address validated, (c) refund address either empty or validated.
- ☐ Address validation runs on blur via
POST /v1/addresses/validate, with visible ✓ or error.
- ☐ After Confirm, the deposit address (
payinAddress) is prominently displayed.
- ☐ Polling runs at 10 s while visible / 30 s when backgrounded / 5 min on
hold / never when terminal.
- ☐ Error display surfaces
error.message and error.param — never silent.
- ☐
partnerReferenceId is set to the developer's own order id so they can correlate swaps with their DB.
If any item fails, fix it before shipping. Partial integrations leak money.
Reference docs (load these when you need depth)
Support
Always include the swap id and the X-Request-Id of the relevant response when escalating. Both make support's lookup hop trivial.