Swaps
Three endpoints for the full swap lifecycle. Swap creation is idempotent — safe to retry.
Create a swap
/v1/swapsValidates the destination address, locks an indicative rate, creates the swap, and returns a deposit address you display to your user.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
Authorization | string | required | Bearer <public_key>:<secret> |
Content-Type | string | required | application/json |
Idempotency-Key | string | required | UUID v4. Repeating returns the original swap, no duplicate created. See Idempotency. |
Request body
| Name | Type | Required | Description |
|---|---|---|---|
from | string | required | Lowercase payin ticker. |
to | string | required | Lowercase payout ticker. |
amountFrom | string | required | Decimal string. Must be within the pair's min/max. |
address | string | required | User's payout (destination) wallet. |
refundAddress | string | optional | Strongly recommended. The user's wallet address on the from chain — the wallet they are sending the deposit from. Used to return funds automatically if the swap can't complete (e.g. amount below min, deposit timed out). Swaps created without a refundAddress are accepted but are markedly more likely to need manual support intervention to recover funds; we recommend passing it on every swap. |
partnerReferenceId | string | optional | Your internal order ID. Stored on the swap row for cross-reference. Up to 120 chars. |
mode | string | optional | "float" (default) or "fixed". For "fixed", see Fixed-rate swaps below — rateId and refundAddress become required. |
rateId | string | optional | Required when mode is "fixed": the rateId from a prior fixed-rate quote. Ignored for float swaps. |
Example
import { randomUUID } from 'node:crypto';
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
from: 'btc',
to: 'eth',
amountFrom: '0.01',
address: '0xUserPayoutAddress...',
refundAddress: 'bc1qUserRefundAddress...',
partnerReferenceId: 'order_42',
}),
});
const { swap } = await res.json();
// Display swap.payinAddress to the user. They should send swap.amountFrom there.
// After completion, read swap.amountActualFrom to know what arrived on-chain.Response (HTTP 201)
{
"swap": {
"id": "htpi6bqnazl7hbjd",
"providerSwapId": "htpi6bqnazl7hbjd",
"status": "waiting",
"mode": "float",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountExpectedFrom": "0.01",
"amountExpectedTo": "0.1532",
"amountActualFrom": null,
"amountActualTo": null,
"networkFee": "0.0021",
"actualNetworkFee": null,
"rate": "15.32",
"payinAddress": "bc1qDepositAddressFromUs...",
"payoutAddress": "0xUserPayoutAddress...",
"refundAddress": "bc1qUserRefundAddress...",
"payinHash": null,
"payoutHash": null,
"moneyReceivedAt": null,
"moneySentAt": null,
"amountAnomalyPct": null,
"partnerReferenceId": "order_42",
"payTill": null,
"createdAt": "2026-04-29T12:00:00.000Z"
}
}Requested amount vs actual amount
amountFrom is the requested deposit amount from swap creation and never changes. Once the swap progresses, GhostSwap snapshots the amount actually received on-chain into amountActualFrom. Partner earnings and processed-volume reporting use amountActualFrom, not the requested amount, so over-payments and under-payments are accounted correctly.
Fields populated after upstream settlement data is available:
| Name | Type | Description |
|---|---|---|
amountExpectedFrom | string | Alias of amountFrom; the requested amount. |
amountActualFrom | string | null | Actual source amount received on-chain. Used for commission and revenue math. |
amountActualTo | string | null | Actual destination amount sent to the user. |
actualNetworkFee | string | null | Actual network fee charged once reported. |
payinHash | string | null | Source-chain transaction hash once known. |
payoutHash | string | null | Destination-chain transaction hash once known. |
moneyReceivedAt | string | null | ISO timestamp when the deposit was observed, once known. |
moneySentAt | string | null | ISO timestamp when payout was sent, once known. |
amountAnomalyPct | string | null | Percent delta between actual and requested amount. Example: 20 means the user sent 20% more than requested. |
About the swap id
The id is the canonical identifier for the swap. The same string identifies the swap in your records, in ours, and in our upstream liquidity provider's records — when you escalate a stuck swap to support@ghostswap.io, paste this id verbatim and we forward it straight upstream without a lookup hop.
| Field | Format | Use it for |
|---|---|---|
id | 16-char hex (e.g. htpi6bqnazl7hbjd) for swaps created on or after the unified-id rollout; legacy swp_* format for historical swaps. Both work everywhere. | All calls back into our API (GET /v1/swaps/:id, idempotency-key correlation), and any support escalation. Store this. |
providerSwapId | Identical to id for new swaps. Kept in the response for backwards compatibility with integrations written against the previous two-id shape. | Nothing new — equal to id. Safe to ignore in new code; safe to keep reading if your existing code already does. |
If your integration is older and stored swp_* ids before the rollout, those keep working forever — GET /v1/swaps/{swp_xxxxxxxx} still resolves correctly. No code change is required on your side to support both formats.
Fixed-rate swaps
The default swap is float-rate — the rate is re-quoted at creation and floats until the deposit confirms. To create a swap at a locked rate instead, first get a fixed quote from POST /v1/quotes with mode: "fixed", then create the swap with three additions:
mode: "fixed"rateId— the token from that fixed quote (valid ~60 seconds)refundAddress— required for fixed-rate swaps (it is optional for float)
Send the same amountFrom you quoted — the locked rate is bound to that exact input amount.
import { randomUUID } from 'node:crypto';
// 1. Fixed quote
const { quote } = await fetch(`${BASE}/v1/quotes`, {
method: 'POST',
headers: { 'Authorization': AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'btc', to: 'eth', amountFrom: '0.01', mode: 'fixed' }),
}).then((r) => r.json());
// 2. Create the swap with the rateId — promptly, before quote.expiresAt
const res = await fetch(`${BASE}/v1/swaps`, {
method: 'POST',
headers: {
'Authorization': AUTH,
'Content-Type': 'application/json',
'Idempotency-Key': randomUUID(),
},
body: JSON.stringify({
from: 'btc',
to: 'eth',
amountFrom: '0.01',
address: '0xUserPayoutAddress...',
refundAddress: 'bc1qUserRefundAddress...',
mode: 'fixed',
rateId: quote.rateId,
}),
});
const { swap } = await res.json();A fixed-rate swap response sets "mode": "fixed" and a non-null payTill — the deadline by which the user must deposit:
{
"swap": {
"id": "htpi6bqnazl7hbjd",
"providerSwapId": "htpi6bqnazl7hbjd",
"status": "waiting",
"mode": "fixed",
"from": "btc",
"to": "eth",
"amountFrom": "0.01",
"amountExpectedFrom": "0.01",
"amountExpectedTo": "0.1532",
"amountActualFrom": null,
"amountActualTo": null,
"networkFee": "0.0021",
"actualNetworkFee": null,
"rate": "15.32",
"payinAddress": "bc1qDepositAddressFromUs...",
"payoutAddress": "0xUserPayoutAddress...",
"refundAddress": "bc1qUserRefundAddress...",
"payinHash": null,
"payoutHash": null,
"moneyReceivedAt": null,
"moneySentAt": null,
"amountAnomalyPct": null,
"partnerReferenceId": "order_42",
"payTill": "2026-05-21T18:15:00.000Z",
"createdAt": "2026-05-21T18:00:00.000Z"
}
}| Name | Type | Description |
|---|---|---|
mode | string | "fixed" for a fixed-rate swap, "float" otherwise. Always present. |
payTill | string | null | Fixed-rate deposit deadline (ISO 8601). The user must send amountFrom before this instant. null for float swaps. |
Rules for fixed-rate swaps:
- Deposit exactly
amountFrombeforepayTill. A late or short deposit drops the swap to statusexpiredand the locked rate is lost — see Status lifecycle. rateIdis valid ~60 seconds. If it has lapsed (or was already used) by the time of this call, you getrate_expired(HTTP 409). Request a fresh fixed quote and retry with a newIdempotency-Key— a newrateIdis a new logical attempt, so reusing the previous key (bound to the expiredrateId) returnsidempotency_key_mismatch.rateIdandrefundAddressare both required formode: "fixed". Omitting them returnsmissing_rate_id/missing_refund_address(HTTP 400), checked before any swap is created.- Float swaps are unaffected — omit
mode(or sendmode: "float") and the request and response shapes are exactly as documented above, with"mode": "float"and"payTill": null.
Get a swap
/v1/swaps/:idFetches the current state of a swap. Use the id returned by the create response.
Example
const res = await fetch(`${BASE}/v1/swaps/htpi6bqnazl7hbjd`, {
headers: { 'Authorization': AUTH },
});
const { swap } = await res.json();
console.log(swap.status); // "waiting", "confirming", "finished", ...Status updates flow from a background worker that polls upstream every ~30 seconds. Poll this endpoint at any cadence; we return our database state. See Status lifecycle.
List swaps
/v1/swapsPaginated, most-recent first, scoped to your organization.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
limit | integer | optional | 1–100. Default 50. |
offset | integer | optional | Pagination cursor. |
Example
const res = await fetch(`${BASE}/v1/swaps?limit=50&offset=0`, {
headers: { 'Authorization': AUTH },
});
const { swaps } = await res.json();Response
{
"swaps": [
{ "id": "htpi6bqnazl7hbjd", "status": "finished", ... },
{ "id": "kx9j3mfp2qaw7rty", "status": "confirming", ... }
]
}Errors
| Type / code | When |
|---|---|
validation_error (missing_idempotency_key, HTTP 400) | The Idempotency-Key header is required on POST /v1/swaps. Generate a UUID v4 once per Confirm action and reuse it on retries — never regenerate per HTTP attempt. |
validation_error (amount_below_min, HTTP 400, param: amountFrom) | amountFrom is below the pair's minimum. Error message includes the actual minimum. Pre-validate locally with /v1/pairs?from=…&to=… to avoid this on the swap-creation path. |
validation_error (amount_above_max, HTTP 400, param: amountFrom) | amountFrom is above the pair's maximum. Same pre-validation guidance as amount_below_min. |
validation_error (pair_unsupported, HTTP 400, param: pair) | The from/to pair is not currently available for the requested mode (float or fixed). |
validation_error (missing_rate_id, HTTP 400) | mode: "fixed" was sent without a rateId. Get one from POST /v1/quotes with mode: "fixed" and pass it within ~60 seconds. |
validation_error (missing_refund_address, HTTP 400) | mode: "fixed" was sent without a refundAddress. It is required for fixed-rate swaps. |
validation_error (field: 'address') | Destination address failed upstream validation. |
validation_error (other) | Missing field, invalid currency, generic body validation failure. |
authentication_error (unauthenticated, HTTP 401) | Missing or invalid Authorization header. Also returned when the credential itself was revoked — issue a new one. |
authorization_error (org_pending_review, HTTP 403) | Your organization is still under admin review. The API will start accepting calls once approved. No action needed; you'll be emailed when ready. |
authorization_error (org_suspended, HTTP 403) | Your organization has been suspended. The credential itself is fine — contact support@ghostswap.io. |
authorization_error (org_rejected, HTTP 403) | Your partner application was not approved. Contact support@ghostswap.io for details. |
not_found (HTTP 404) | Swap id doesn't exist or doesn't belong to your org. |
conflict (idempotency_key_mismatch, HTTP 409) | Idempotency-Key reused with a different request body. |
conflict (rate_expired, HTTP 409) | The fixed-rate rateId has expired (~60s window) or was already used. Request a fresh fixed quote and retry. |
unprocessable (exchange_not_processable, HTTP 422) | We can't route this specific swap. Don't retry — surface the response message to your user verbatim. The message is identical to what GhostSwap's own consumer product shows in the same case and is intentionally short with no remediation hint. |
rate_limit_error (rate_limited, HTTP 429) | Rate limit exceeded. Comes with Retry-After and RateLimit-* headers. See Rate limits. |
upstream_error (provider_credential_pending, HTTP 503) | Your account is still being activated. Fee-sensitive quotes and swap creation enable once activation completes. See Troubleshooting. |
upstream_error (upstream_bad_response, HTTP 502) | Liquidity provider returned a non-JSON response. On POST /v1/swaps specifically, do not auto-retry — call GET /v1/swaps?limit=20 and check for an existing row with your partnerReferenceId first. See Troubleshooting for the recovery procedure. |
upstream_error (other) | Generic liquidity-layer issue. Back off and retry with the same Idempotency-Key (safe on read endpoints; on POST /v1/swaps, follow the recovery procedure above). |
See Errors for the envelope shape and recovery strategy per type.