API reference

DUALKEEP API

A JSON REST API for accepting crypto payments: hosted checkout, invoices, signed webhooks, balances and payouts. All 24 endpoints below are generated from the OpenAPI contract the gateway is built against, so this page cannot drift from what the server actually accepts.

Base URL
https://dualkeep.com/api/v1
Auth
X-API-Key
Contract
OpenAPI 1.0.0

Introduction

DUALKEEP is a custodial cryptocurrency payment gateway. It issues a fresh deposit address per invoice, monitors the chain for incoming payments, confirms them against a per-currency confirmation policy, credits the merchant's USD ledger balance immediately on confirmation (minus the service fee), delivers signed webhooks, consolidates deposits into a hot treasury wallet in the background, and pays out merchant withdrawals in periodic batches.

Getting started

The fastest integration is the hosted checkout, and it is three steps:

  • POST /api/v1/payment/initiate with your order id and a USD amount.
  • Redirect the customer to https://dualkeep.com/pay/{payment_id}, where they pick a coin, get an address and a QR code, and pay.
  • Take the signed payment.paid webhook on your callback_url and fulfil the order. Never fulfil on the browser redirect alone.

Build your own payment UI instead with POST /api/v1/payment, which returns a deposit address and exact amount for one chosen currency.

API keys come with a merchant account and are generated on the Integration page of the dashboard. If you use WooCommerce, Joomla or Drupal Commerce, install the plugin and paste the key: it performs the calls above for you.

Authentication

Every endpoint under /api/v1 requires an API key sent in the X-API-Key request header. Keys are generated on the Integration page of the merchant dashboard and are shown only once. The server stores only a bcrypt hash of the key plus an indexed public prefix.

The /health endpoint is public (unauthenticated), and so are the buyer checkout endpoints (/api/v1/payment/{payment_id}/asset|rate|fix-price| wallet|status|email): the customer's browser holds no API key, so the unguessable UUID payment_id acts as the capability token. Public endpoints are rate-limited by client IP. POST /api/v1/payment/initiate itself is merchant-authenticated.

Amounts

All cryptocurrency amounts are decimal strings, never floating-point, so full precision survives the round trip. Parse them with a decimal type - 0.1 + 0.2 in a binary float is not 0.3, and the difference is money. Fiat amounts on input accept a JSON number or a decimal string; the exact digits you send are preserved.

Errors

Every error uses conventional HTTP status codes with the same JSON envelope, so one handler covers the whole API:

{ "error": { "code": "validation_error", "message": "..." } }

Branch on code, not on message: codes are stable, messages are written for humans and may be reworded. Each endpoint below lists the codes it can return.

CodeStatusMeaning
validation_error400The request was malformed or failed validation.
unsupported_currency400That currency code is not offered.
unauthorized401Missing or invalid API key.
forbidden403The account is suspended.
not_found404No such resource, or it belongs to another merchant.
insufficient_balance402The available balance does not cover the request.
invalid_payment_state409The resource is not in a state that permits the operation.
rate_limited429Slow down and retry (see below).
upstream_error502A blockchain or rate provider failed. Safe to retry.
internal_error500Unexpected server error.

Rate limits

Merchant calls are limited per API key; public checkout calls are limited per client IP. The default allowance is 100 requests per minute with a burst of 20. Over the limit you get 429 rate_limited - back off and retry rather than looping, and poll payment status on an interval rather than continuously.

Idempotency

Creating a payment twice by accident should not charge a customer twice, so the create calls are idempotent on your own reference: POST /api/v1/payment/initiate on order_id and POST /api/v1/payment on merchant_order_id. Repeating a create with the same reference returns the session that already exists instead of opening a second one, and the uniqueness is enforced in the database, so two concurrent retries still resolve to one payment.

Withdrawals take an explicit idempotency_key (or the Idempotency-Key header) for the same reason.

Supported currencies

Call GET /api/v1/currencies for the live list: which assets a deployment offers depends on the chain providers it is configured for, so any list written down here goes stale. Codes carry their network where an asset exists on several - USDT_ERC20 (Ethereum), USDT_TRC20 (TRON), USDC_SPL (Solana), and so on.

Payment lifecycle (status)

StatusMeaning
pendingInvoice created, awaiting first on-chain sighting.
confirmingFully funded, awaiting required confirmation depth. Never expired.
partially_paidReceived less than the requested amount.
paidConfirmed portion covers the requested amount; webhook payment.paid fired. Never regresses.
expiredpending/partially_paid past expires_at. Monitored for a 24h grace window; a late full payment transitions it to paid.
failedUnrecoverable error.
refundedFully refunded back to the customer.

amount_received is the sum of all incoming transactions (split payments across several transactions complete). A payment becomes paid only when the confirmed portion covers the requested amount.

Buyer checkout status

The buyer checkout endpoints expose a buyer-facing status vocabulary (waiting | processing | success | expired | failed) mapped from the internal lifecycle: pendingwaiting, confirming / partially_paidprocessing, paid / refundedsuccess.

Webhooks

When an invoice has a callback_url, DUALKEEP POSTs a WebhookPayload JSON body on state changes. Each request includes:

  • X-CryptoGate-Event: the event name (payment.paid, payment.expired).
  • X-CryptoGate-Signature: lowercase-hex HMAC-SHA256 of the raw request body, keyed by the merchant webhook secret.
  • X-CryptoGate-Delivery: the numeric delivery id.

The signing key is the per-merchant webhook_secret (whsec_<64 hex>) shown once when the merchant account is provisioned. Keep it server-side, next to the API key, and never ship either in frontend code.

Verify the signature against the raw request body before trusting the payload - not against a re-serialised object, whose byte order will differ:

import crypto from "node:crypto";

// express: app.post(path, express.raw({type: "application/json"}), handler)
function verify(rawBody, header, secret) {
const expected = crypto.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(header || "", "utf8");
// Length-check first: timingSafeEqual throws on a length mismatch.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Deliveries are retried with exponential backoff until your endpoint returns 2xx, so the same event can arrive more than once: key your handler on payment_id and ignore an event you have already applied. Answer 2xx as soon as you have stored the event and do the slow work afterwards - a handler that takes too long looks like a failure and gets replayed.

E-commerce plugins

If you sell through a supported platform there is no need to call this API directly. Install the DUALKEEP payment gateway plugin for WooCommerce, Joomla or Drupal Commerce, paste your API key, and crypto appears as a payment method at checkout. The plugins use the hosted-checkout flow described above.

Payments

Invoice creation, status, listing.

Create a payment (invoice)

POST /api/v1/paymentAPI key

Derives a fresh receiving address from the merchant's HD wallet, persists an invoice, and returns it together with a QR code and a payment URI. Supply either amount (crypto) or fiat_amount + fiat_currency (the gateway converts to crypto at the current rate).

merchant_order_id is an idempotency key: creating again with the same value returns the existing payment (enforced by a unique constraint, so concurrent duplicate creates also resolve to one payment).

XRP invoices use the merchant's shared XRP hot account plus a per-invoice destination_tag (also encoded in payment_uri as ripple:<addr>?amount=X&dt=N); customers must include the tag.

XLM invoices likewise use the merchant's shared XLM hot account plus a per-invoice ID memo, returned as memo / memo_type (also encoded in payment_uri as a SEP-0007 URI: web+stellar:pay?destination=G...&amount=X&memo=N&memo_type=MEMO_ID); customers must include the memo (an ID memo, or a text memo with exactly the numeric ID; both are accepted).

Body parameters

NameTypeDescription
merchant_order_idstringYour own order reference (optional, indexed for lookup).
currency requiredstringA supported currency code, e.g. BTC, ETH, USDT_TRC20, USDC_SPL. Call GET /api/v1/currencies for the live list, including network and confirmation policy per asset.
amountstringCrypto amount as a decimal string (preferred). Either amount or fiat_amount + fiat_currency must be supplied.
fiat_amountnumber or stringFiat amount; converted to crypto at the current rate. Accepts a JSON number or a decimal string; the exact digits are preserved (no binary floating point in the money path).
fiat_currencystringFiat currency for fiat_amount (e.g. USD).
callback_urlstring (url)Webhook URL notified on payment events. Validated: in production (APP_ENV=production) it must be https and must not target private/loopback/internal hosts.
success_urlstring (url)Same URL validation as callback_url.
cancel_urlstring (url)Same URL validation as callback_url.
customer_emailstring
expires_inintegerInvoice validity in seconds (default 3600). Values outside 60..2592000 (30 days) are rejected with 400 validation_error.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
502upstream_errorA blockchain or rate provider failed or is unavailable.
curl -X POST "https://dualkeep.com/api/v1/payment" \
  -H "X-API-Key: $DUALKEEP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "merchant_order_id": "order-1001",
    "currency": "BTC",
    "amount": "0.0125",
    "callback_url": "https://shop.example.com/webhooks/dualkeep",
    "success_url": "https://shop.example.com/thanks",
    "cancel_url": "https://shop.example.com/cart",
    "customer_email": "[email protected]",
    "expires_in": 3600
  }'
const payload = {
  "merchant_order_id": "order-1001",
  "currency": "BTC",
  "amount": "0.0125",
  "callback_url": "https://shop.example.com/webhooks/dualkeep",
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart",
  "customer_email": "[email protected]",
  "expires_in": 3600
};

const res = await fetch("https://dualkeep.com/api/v1/payment", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "merchant_order_id": "order-1001",
  "currency": "BTC",
  "amount": "0.0125",
  "callback_url": "https://shop.example.com/webhooks/dualkeep",
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart",
  "customer_email": "[email protected]",
  "expires_in": 3600
}

res = requests.post(
    "https://dualkeep.com/api/v1/payment",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := `{
	  "merchant_order_id": "order-1001",
	  "currency": "BTC",
	  "amount": "0.0125",
	  "callback_url": "https://shop.example.com/webhooks/dualkeep",
	  "success_url": "https://shop.example.com/thanks",
	  "cancel_url": "https://shop.example.com/cart",
	  "customer_email": "[email protected]",
	  "expires_in": 3600
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/payment", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
201 Created
{
  "payment_id": "pay_2f3c1b7e",
  "address": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxxx",
  "amount": "0.0125",
  "currency": "BTC",
  "network": "native",
  "fiat_amount": "750.00",
  "fiat_currency": "USD",
  "expires_at": "2026-06-24T13:00:00Z",
  "status": "pending",
  "confirmations_required": 6,
  "qr_code": "data:image/png;base64,iVBORw0KGgo...",
  "payment_uri": "bitcoin:bc1qexample...?amount=0.0125"
}

Get payment status

GET /api/v1/payment/{payment_id}API key

Returns the current status of a payment owned by the authenticated merchant.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X GET "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_2f3c1b7e",
  "merchant_order_id": "order-1001",
  "currency": "BTC",
  "network": "native",
  "address": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxxx",
  "amount": "0.0125",
  "amount_received": "0.0125",
  "fiat_amount": "750.00",
  "fiat_currency": "USD",
  "status": "paid",
  "confirmations": 6,
  "confirmations_required": 6,
  "tx_hash": "9a1b...e7",
  "expires_at": "2026-06-24T13:00:00Z",
  "paid_at": "2026-06-24T12:14:08Z",
  "created_at": "2026-06-24T12:00:00Z"
}

List payments

GET /api/v1/paymentsAPI key

Returns a paginated list of the merchant's payments, optionally filtered by status.

Query parameters

NameTypeDescription
limitintegerPage size (default 20, max 100). Defaults to 20.
offsetintegerNumber of records to skip (maximum 100000). Defaults to 0.
statusstringFilter by payment status. One of: pending, confirming, partially_paid, paid, expired, failed, refunded.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X GET "https://dualkeep.com/api/v1/payments?limit=20&offset=0" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/payments?limit=20&offset=0", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payments?limit=20&offset=0",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payments?limit=20&offset=0", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "data": [
    {
      "payment_id": "pay_2f3c1b7e",
      "merchant_order_id": "order-1001",
      "currency": "BTC",
      "network": "native",
      "address": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxxx",
      "amount": "0.0125",
      "amount_received": "0.0125",
      "status": "paid",
      "confirmations": 6,
      "confirmations_required": 6,
      "tx_hash": "9a1bc4d5e6f70819a2b3c4d5e6f7081920a1b2c3",
      "expires_at": "2026-06-24T13:00:00Z",
      "created_at": "2026-06-24T12:00:00Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Checkout

Buyer (hosted checkout) flow - merchant-initiated USD sessions paid by the customer.

Initiate a buyer checkout session

POST /api/v1/payment/initiateAPI key

Opens a checkout session for an order priced in USD. No currency or deposit address is assigned yet - the customer selects the asset with POST /api/v1/payment/{payment_id}/asset.

Idempotent on (merchant, order_id): repeating the call with the same order_id returns the existing session (enforced by a unique constraint, so concurrent duplicate initiations also resolve to one session).

The response lists every payable asset with its current USD rate (rate_usd is omitted per asset when the rate oracle is unavailable) and uses the buyer-facing status vocabulary (waiting on creation).

Body parameters

NameTypeDescription
order_id requiredstringYour order reference. Idempotency key: repeating the call with the same value returns the existing session.
product_name requiredstringShown on the payment page and in the confirmation email.
amount_usd requirednumber or stringUSD amount. Accepts a JSON number or a decimal string; the exact digits are preserved (no binary floating point in the money path). Must be positive.
emailstringPre-registers the customer for a confirmation email (max 255 chars).
callback_urlstring (url)Webhook URL notified on payment events. Same validation as the classic callback_url (https + no private/internal hosts in production).
success_urlstring (url)Redirect target on success. Same URL validation as callback_url.
expires_inintegerSession validity in seconds (default 3600). Values outside 60..2592000 (30 days) are rejected with 400 validation_error.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X POST "https://dualkeep.com/api/v1/payment/initiate" \
  -H "X-API-Key: $DUALKEEP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "order-1001",
    "product_name": "Pro plan (1 year)",
    "amount_usd": "49.99",
    "email": "[email protected]",
    "callback_url": "https://shop.example.com/webhooks/dualkeep",
    "success_url": "https://shop.example.com/thanks",
    "expires_in": 3600
  }'
const payload = {
  "order_id": "order-1001",
  "product_name": "Pro plan (1 year)",
  "amount_usd": "49.99",
  "email": "[email protected]",
  "callback_url": "https://shop.example.com/webhooks/dualkeep",
  "success_url": "https://shop.example.com/thanks",
  "expires_in": 3600
};

const res = await fetch("https://dualkeep.com/api/v1/payment/initiate", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "order_id": "order-1001",
  "product_name": "Pro plan (1 year)",
  "amount_usd": "49.99",
  "email": "[email protected]",
  "callback_url": "https://shop.example.com/webhooks/dualkeep",
  "success_url": "https://shop.example.com/thanks",
  "expires_in": 3600
}

res = requests.post(
    "https://dualkeep.com/api/v1/payment/initiate",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := `{
	  "order_id": "order-1001",
	  "product_name": "Pro plan (1 year)",
	  "amount_usd": "49.99",
	  "email": "[email protected]",
	  "callback_url": "https://shop.example.com/webhooks/dualkeep",
	  "success_url": "https://shop.example.com/thanks",
	  "expires_in": 3600
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/payment/initiate", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
201 Created
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "order_id": "order-1001",
  "product_name": "Pro plan (1 year)",
  "amount_usd": "49.99",
  "status": "waiting",
  "expires_at": "2026-06-24T13:00:00Z",
  "supported_assets": [
    {
      "code": "BTC",
      "name": "Bitcoin",
      "network": "native",
      "decimals": 8,
      "confirmations_required": 6,
      "rate_usd": "60000.00"
    }
  ]
}

Select the payment asset (public)

POST /api/v1/payment/{payment_id}/assetNo key

The customer picks the crypto asset. The gateway derives the per-payment deposit address (XRP/XLM use the merchant's shared hot account plus the per-payment destination tag / ID memo), converts the USD amount at the live rate, and starts the rate window (CHECKOUT_RATE_TTL, default 15 minutes).

The asset can be re-selected as long as no funds have been seen; once a deposit is observed (or the session is settled/expired) it is locked (409 invalid_payment_state).

Public: no API key - the unguessable payment_id is the capability token; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Body parameters

NameTypeDescription
asset requiredstringA supported currency code, e.g. BTC, ETH, USDT_TRC20, USDC_SPL. Call GET /api/v1/currencies for the live list, including network and confirmation policy per asset.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
404not_foundThe requested resource was not found.
409invalid_payment_stateThe payment is not in a state that permits this operation.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
502upstream_errorA blockchain or rate provider failed or is unavailable.
curl -X POST "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/asset" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "BTC"
  }'
const payload = {
  "asset": "BTC"
};

const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/asset", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "asset": "BTC"
}

res = requests.post(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/asset",
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

func main() {
	body := `{
	  "asset": "BTC"
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/asset", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "asset": "BTC",
  "network": "native",
  "amount_usd": "49.99",
  "rate": "60000.00",
  "crypto_amount": "0.00083317",
  "rate_fixed": false,
  "rate_expires_at": "2026-06-24T12:15:00Z",
  "confirmations_required": 6,
  "rules": [
    "Send the exact amount shown; payments below it stay incomplete until topped up.",
    "The payment is confirmed after 6 network confirmation(s)."
  ]
}

Current conversion rate (public)

GET /api/v1/payment/{payment_id}/rateNo key

Returns the current conversion for the payment. For the selected asset, a still-valid (or fixed) rate window returns the stored rate; a lapsed window refreshes the rate at the live price and persists it (rates are no longer refreshed once funds have been observed). Passing a different asset than the selected one returns a non-persisted preview (preview: true).

Public: no API key; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Query parameters

NameTypeDescription
assetstringA supported currency code. Defaults to the selected asset; a different code returns a non-persisted preview. Required when no asset has been selected yet.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
502upstream_errorA blockchain or rate provider failed or is unavailable.
curl -X GET "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/rate"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/rate", {
  method: "GET",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/rate",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/rate", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "asset": "BTC",
  "network": "native",
  "amount_usd": "49.99",
  "rate": "60000.00",
  "crypto_amount": "0.00083317",
  "rate_fixed": false,
  "rate_expires_at": "2026-06-24T12:15:00Z",
  "confirmations_required": 6,
  "rules": [
    "Send the exact amount shown; a short payment stays incomplete until topped up.",
    "The payment is confirmed after 6 network confirmations."
  ]
}

Fix the current price (public)

POST /api/v1/payment/{payment_id}/fix-priceNo key

Locks the current rate for the fix window (CHECKOUT_FIX_TTL, default 10 minutes) and disables auto-refresh until it lapses. Requires an asset to be selected and the session to still be awaiting funds.

Public: no API key; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Errors

StatusCodeWhen
404not_foundThe requested resource was not found.
409invalid_payment_stateThe payment is not in a state that permits this operation.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
502upstream_errorA blockchain or rate provider failed or is unavailable.
curl -X POST "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/fix-price"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/fix-price", {
  method: "POST",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.post(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/fix-price",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/fix-price", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "asset": "BTC",
  "network": "native",
  "amount_usd": "49.99",
  "rate": "60000.00",
  "crypto_amount": "0.00083317",
  "rate_fixed": true,
  "rate_expires_at": "2026-06-24T12:24:00Z",
  "confirmations_required": 6
}

Deposit address and QR codes (public)

GET /api/v1/payment/{payment_id}/walletNo key

Returns the deposit details the payment page renders: the address, two QR codes (base64 PNG data URIs - one of the bare address, one of the payment URI with the amount), the payment URI, and the timers. For XRP the response carries destination_tag; for XLM memo / memo_type (always id).

Public: no API key; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Errors

StatusCodeWhen
404not_foundThe requested resource was not found.
409invalid_payment_stateThe payment is not in a state that permits this operation.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X GET "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/wallet"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/wallet", {
  method: "GET",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/wallet",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/wallet", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "asset": "BTC",
  "network": "native",
  "address": "bc1qexampleaddressxxxxxxxxxxxxxxxxxxxxx",
  "crypto_amount": "0.00083317",
  "rate": "60000.00",
  "qr_code_address": "data:image/png;base64,iVBORw0KGgo...",
  "qr_code_uri": "data:image/png;base64,iVBORw0KGgo...",
  "payment_uri": "bitcoin:bc1qexample...?amount=0.00083317",
  "rate_fixed": false,
  "rate_expires_at": "2026-06-24T12:15:00Z",
  "expires_at": "2026-06-24T13:00:00Z"
}

Checkout session view (public)

GET /api/v1/payment/{payment_id}/sessionNo key

Returns the session view the hosted checkout page boots from - the same shape as the initiate response (order details plus supported assets with current USD rates). The gateway also serves a ready-made checkout page at GET /checkout/{payment_id} driving these endpoints.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Errors

StatusCodeWhen
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/session"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/session", {
  method: "GET",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/session",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/session", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "order_id": "order-1001",
  "product_name": "Pro plan (1 year)",
  "amount_usd": "49.99",
  "status": "waiting",
  "expires_at": "2026-06-24T13:00:00Z",
  "merchant_name": "Acme Store",
  "supported_assets": [
    {
      "code": "BTC",
      "name": "Bitcoin",
      "network": "native",
      "decimals": 8,
      "confirmations_required": 6,
      "rate_usd": "60000.00"
    },
    {
      "code": "USDT_TRC20",
      "name": "Tether USD (TRC-20)",
      "network": "TRC20",
      "decimals": 6,
      "confirmations_required": 20,
      "rate_usd": "1.00"
    }
  ]
}

Buyer-facing payment status (public)

GET /api/v1/payment/{payment_id}/statusNo key

The status the checkout frontend polls, in the buyer-facing vocabulary (waiting | processing | success | expired | failed). asset, network, and crypto_amount are empty until the customer selects an asset.

Public: no API key; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Errors

StatusCodeWhen
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X GET "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/status"
const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/status", {
  method: "GET",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/status",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/status", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "payment_id": "pay_6f0c2a4e-6d9b-4f0e-9df3-6b1d2c8a7e51",
  "order_id": "order-1001",
  "product_name": "Pro plan (1 year)",
  "status": "success",
  "asset": "BTC",
  "network": "native",
  "crypto_amount": "0.00083317",
  "amount_received": "0.00083317",
  "confirmations": 6,
  "confirmations_required": 6,
  "tx_hash": "9a1b...e7",
  "paid_at": "2026-06-24T12:14:08Z",
  "success_url": "https://shop.example.com/thanks"
}

Register the customer email (public)

POST /api/v1/payment/{payment_id}/emailNo key

Registers (or replaces) the customer's notification email on the payment. A confirmation email is sent when the payment confirms.

Public: no API key; rate-limited by client IP.

Path parameters

NameTypeDescription
payment_id requiredstringThe DUALKEEP payment id (e.g. pay_2f3c1b7e).

Body parameters

NameTypeDescription
email requiredstringCustomer email for the confirmation notification (max 255 chars).

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X POST "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/email" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]"
  }'
const payload = {
  "email": "[email protected]"
};

const res = await fetch("https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/email", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "email": "[email protected]"
}

res = requests.post(
    "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/email",
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
)

func main() {
	body := `{
	  "email": "[email protected]"
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/payment/pay_2f3c1b7e/email", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "status": "ok"
}

Billing

Balance, ledger history, and Add Funds top-ups.

Balance overview

GET /api/v1/merchant/balanceAPI key

The merchant's USD ledger balance, the portion locked by in-flight withdrawals (withdrawal_pending_usd), the plan, and the fee rate deducted from each confirmed payment. Every confirmed payment credits (usd value - fee) to the balance immediately.

available_usd is the balance net of the platform fee: the largest amount a withdrawal can be requested for, and the figure the dashboard shows as "available balance". Withdraw against that, not balance_usd.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X GET "https://dualkeep.com/api/v1/merchant/balance" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/balance", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/balance",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/balance", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "balance_usd": "1250.00",
  "withdrawal_pending_usd": "0.00",
  "available_usd": "1245.00",
  "plan": "standard",
  "fee_rate_percent": "0.4",
  "low_balance_threshold_usd": "25.00"
}

Add funds (self-service top-up)

POST /api/v1/merchant/top-upAPI key

Creates a deposit request: a unique deposit address (or the shared hot account plus tag/memo matching on XRP, XLM and TON), the exact crypto amount at the live rate, a QR code, and a payment URI. The monitor detects the deposit and the balance is credited automatically once it confirms. Every supported asset is accepted.

Body parameters

NameTypeDescription
amount_usd requiredstringUSD amount to add to the balance.
asset requiredstringA supported currency code, e.g. BTC, ETH, USDT_TRC20, USDC_SPL. Call GET /api/v1/currencies for the live list, including network and confirmation policy per asset.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
500internal_errorUnexpected server error.
curl -X POST "https://dualkeep.com/api/v1/merchant/top-up" \
  -H "X-API-Key: $DUALKEEP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usd": "50",
    "asset": "USDT_TRC20"
  }'
const payload = {
  "amount_usd": "50",
  "asset": "USDT_TRC20"
};

const res = await fetch("https://dualkeep.com/api/v1/merchant/top-up", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "amount_usd": "50",
  "asset": "USDT_TRC20"
}

res = requests.post(
    "https://dualkeep.com/api/v1/merchant/top-up",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := `{
	  "amount_usd": "50",
	  "asset": "USDT_TRC20"
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/merchant/top-up", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
201 Created
{
  "topup_id": "top_9c31d2",
  "amount_usd": "50",
  "asset": "USDT_TRC20",
  "crypto_amount": "50.00",
  "address": "TExampleDepositAddressXXXXXXXXXX",
  "payment_uri": "tron:TExampleDepositAddressXXXXXXXXXX?amount=50",
  "qr_code": "data:image/png;base64,iVBORw0KGgo...",
  "reference": "TU-9C31D2",
  "status": "waiting",
  "confirmations_required": 20,
  "instructions": "Send exactly 50.00 USDT_TRC20 to the address above.",
  "expires_at": "2026-06-24T13:00:00Z",
  "created_at": "2026-06-24T12:00:00Z"
}

Top-up live status

GET /api/v1/merchant/top-ups/{topup_id}API key

Poll while waiting: status walks waiting -> processing -> confirming_credit -> credited as the deposit confirms on-chain.

Path parameters

NameTypeDescription
topup_id requiredstringThe top-up id returned at creation.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/top-ups/top_9c31d2" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/top-ups/top_9c31d2", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/top-ups/top_9c31d2",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/top-ups/top_9c31d2", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "topup_id": "top_9c31d2",
  "amount_usd": "50",
  "asset": "USDT_TRC20",
  "crypto_amount": "50.00",
  "address": "TExampleDepositAddressXXXXXXXXXX",
  "reference": "TU-9C31D2",
  "status": "credited",
  "confirmations": 20,
  "confirmations_required": 20,
  "credited_at": "2026-06-24T12:40:00Z",
  "created_at": "2026-06-24T12:00:00Z"
}

Top-up history

GET /api/v1/merchant/top-upsAPI key

Paginated top-up history, newest first.

Query parameters

NameTypeDescription
limitintegerPage size (default 20, max 100). Defaults to 20.
offsetintegerNumber of records to skip. Defaults to 0.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/top-ups?limit=20&offset=0" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/top-ups?limit=20&offset=0", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/top-ups?limit=20&offset=0",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/top-ups?limit=20&offset=0", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "data": [
    {
      "topup_id": "top_9c31d2",
      "amount_usd": "50",
      "asset": "USDT_TRC20",
      "status": "credited",
      "reference": "TU-9C31D2",
      "created_at": "2026-06-24T12:00:00Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Balance ledger

GET /api/v1/merchant/ledgerAPI key

Signed balance history with a running balance after every entry. The type is one of payment, topup, fee, adjustment, withdrawal, withdrawal_refund, and ref links the entry back to the payment, top-up or withdrawal that caused it.

Query parameters

NameTypeDescription
limitintegerPage size (default 20, max 100). Defaults to 20.
offsetintegerNumber of records to skip. Defaults to 0.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/ledger?limit=20&offset=0" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/ledger?limit=20&offset=0", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/ledger?limit=20&offset=0",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/ledger?limit=20&offset=0", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "data": [
    {
      "id": 4711,
      "type": "payment",
      "ref": "pay_2f3c1b7e",
      "amount_usd": "49.79",
      "balance_after": "1250.00",
      "created_at": "2026-06-24T12:14:08Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Payment credits

GET /api/v1/merchant/ledger-transactionsAPI key

The credit history in customer terms: one entry per confirmed payment, with the crypto amount, its USD value at credit time, the service fee deducted, and the on-chain tx hash.

Query parameters

NameTypeDescription
limitintegerPage size (default 20, max 100). Defaults to 20.
offsetintegerNumber of records to skip. Defaults to 0.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/ledger-transactions?limit=20&offset=0" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/ledger-transactions?limit=20&offset=0", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/ledger-transactions?limit=20&offset=0",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/ledger-transactions?limit=20&offset=0", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "data": [
    {
      "payment_id": "pay_2f3c1b7e",
      "asset": "BTC",
      "amount": "0.0125",
      "amount_usd": "750.00",
      "fee_usd": "3.00",
      "tx_hash": "9a1bc4d5e6f70819a2b3c4d5e6f7081920a1b2c3",
      "status": "paid",
      "created_at": "2026-06-24T12:14:08Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Withdrawals

Merchant payouts from the hot treasury wallet.

Request a withdrawal

POST /api/v1/merchant/withdrawalsAPI key

Queues a payout of amount_usd, debited from the balance immediately (402 insufficient_balance otherwise), in the chosen asset to the given destination address. The estimated network fee (network_fee_usd) is withheld from the amount. Payouts are processed in periodic batches; the USD amount converts to crypto at the live rate when the batch runs, so estimated_crypto at request time is indicative and crypto_amount is what actually went out.

The destination must be an active whitelisted address (dashboard: Balance -> Withdraw -> Address book). Send idempotency_key, or the Idempotency-Key header, so a retried request returns the original withdrawal instead of opening a second payout.

Body parameters

NameTypeDescription
amount_usd requiredstringGross USD amount; the network fee is withheld from it.
asset requiredstringA supported currency code, e.g. BTC, ETH, USDT_TRC20, USDC_SPL. Call GET /api/v1/currencies for the live list, including network and confirmation policy per asset.
address requiredstringWhitelisted destination address.
destination_tagstringThe memo the payout must carry when the destination identifies accounts by one (XRP destination tag, Stellar memo, TON comment). Exchange deposit addresses are shared between customers, so a payout to one without this arrives unattributed. Ignored by chains that have no memo.
idempotency_keystringMax 64 chars. The Idempotency-Key header wins if both are sent.

Errors

StatusCodeWhen
400validation_errorThe request was malformed or failed validation.
401unauthorizedMissing or invalid API key.
402insufficient_balanceThe available balance does not cover the amount plus fees.
429rate_limitedToo many requests for this API key.
curl -X POST "https://dualkeep.com/api/v1/merchant/withdrawals" \
  -H "X-API-Key: $DUALKEEP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usd": "100",
    "asset": "USDT_TRC20",
    "address": "TExampleDestinationAddressXXXXXX",
    "idempotency_key": "payout-2026-06-24-a"
  }'
const payload = {
  "amount_usd": "100",
  "asset": "USDT_TRC20",
  "address": "TExampleDestinationAddressXXXXXX",
  "idempotency_key": "payout-2026-06-24-a"
};

const res = await fetch("https://dualkeep.com/api/v1/merchant/withdrawals", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

payload = {
  "amount_usd": "100",
  "asset": "USDT_TRC20",
  "address": "TExampleDestinationAddressXXXXXX",
  "idempotency_key": "payout-2026-06-24-a"
}

res = requests.post(
    "https://dualkeep.com/api/v1/merchant/withdrawals",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    json=payload,
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := `{
	  "amount_usd": "100",
	  "asset": "USDT_TRC20",
	  "address": "TExampleDestinationAddressXXXXXX",
	  "idempotency_key": "payout-2026-06-24-a"
	}`

	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/merchant/withdrawals", strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
201 Created
{
  "withdrawal_id": "wd_51ab20",
  "asset": "USDT_TRC20",
  "address": "TExampleDestinationAddressXXXXXX",
  "amount_usd": "100",
  "network_fee_usd": "1.10",
  "service_fee_usd": "0.40",
  "estimated_crypto": "98.90",
  "status": "pending",
  "created_at": "2026-06-24T12:00:00Z"
}

Withdrawal history

GET /api/v1/merchant/withdrawalsAPI key

Paginated payout history, newest first.

Query parameters

NameTypeDescription
limitintegerPage size (default 20, max 100). Defaults to 20.
offsetintegerNumber of records to skip. Defaults to 0.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/withdrawals?limit=20&offset=0" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/withdrawals?limit=20&offset=0", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/withdrawals?limit=20&offset=0",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/withdrawals?limit=20&offset=0", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "data": [
    {
      "withdrawal_id": "wd_51ab20",
      "asset": "USDT_TRC20",
      "address": "TExampleDestinationAddressXXXXXX",
      "amount_usd": "100",
      "network_fee_usd": "1.10",
      "crypto_amount": "98.90",
      "rate_usd": "1.00",
      "tx_hash": "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6",
      "status": "confirmed",
      "created_at": "2026-06-24T12:00:00Z",
      "processed_at": "2026-06-24T12:31:00Z"
    }
  ],
  "total": 1,
  "limit": 20,
  "offset": 0
}

Withdrawal detail

GET /api/v1/merchant/withdrawals/{withdrawal_id}API key

Status walks needs_approval -> pending -> processing -> broadcasting -> sent -> confirmed. failed and canceled both return the amount to the balance.

Path parameters

NameTypeDescription
withdrawal_id requiredstringThe withdrawal id returned when the payout was requested.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
404not_foundThe requested resource was not found.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "withdrawal_id": "wd_51ab20",
  "asset": "USDT_TRC20",
  "address": "TExampleDestinationAddressXXXXXX",
  "amount_usd": "100",
  "network_fee_usd": "1.10",
  "crypto_amount": "98.90",
  "rate_usd": "1.00",
  "tx_hash": "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6",
  "status": "confirmed",
  "created_at": "2026-06-24T12:00:00Z",
  "processed_at": "2026-06-24T12:31:00Z"
}

Cancel a queued withdrawal

POST /api/v1/merchant/withdrawals/{withdrawal_id}/cancelAPI key

Only while the payout is still pending; the amount returns to the balance. Once the batch has picked it up the call returns 409 invalid_payment_state.

Path parameters

NameTypeDescription
withdrawal_id requiredstringThe withdrawal id returned when the payout was requested.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
404not_foundThe requested resource was not found.
409invalid_payment_stateThe payment is not in a state that permits this operation.
429rate_limitedToo many requests for this API key.
curl -X POST "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20/cancel" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20/cancel", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.post(
    "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20/cancel",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("POST", "https://dualkeep.com/api/v1/merchant/withdrawals/wd_51ab20/cancel", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "withdrawal_id": "wd_51ab20",
  "asset": "USDT_TRC20",
  "address": "TExampleDestinationAddressXXXXXX",
  "amount_usd": "100",
  "network_fee_usd": "1.10",
  "status": "canceled",
  "created_at": "2026-06-24T12:00:00Z"
}

Metadata

Supported currencies and live exchange rates.

List supported currencies

GET /api/v1/currenciesAPI key

Returns the currencies the gateway supports with display metadata and confirmation policy.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
curl -X GET "https://dualkeep.com/api/v1/currencies" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/currencies", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/currencies",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/currencies", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "currencies": [
    {
      "code": "BTC",
      "name": "Bitcoin",
      "network": "native",
      "decimals": 8,
      "confirmations_required": 6
    },
    {
      "code": "USDT_ERC20",
      "name": "Tether USD (ERC-20)",
      "network": "ERC20",
      "decimals": 6,
      "confirmations_required": 12
    }
  ]
}

Live exchange rates

GET /api/v1/ratesAPI key

Returns live crypto prices quoted in the requested fiat currency (default USD).

Query parameters

NameTypeDescription
fiatstringISO 4217 fiat currency code (default USD). Defaults to USD.

Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key.
429rate_limitedToo many requests for this API key.
502upstream_errorA blockchain or rate provider failed or is unavailable.
curl -X GET "https://dualkeep.com/api/v1/rates?fiat=USD" \
  -H "X-API-Key: $DUALKEEP_API_KEY"
const res = await fetch("https://dualkeep.com/api/v1/rates?fiat=USD", {
  method: "GET",
  headers: {
    "X-API-Key": process.env.DUALKEEP_API_KEY,
  },
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/api/v1/rates?fiat=USD",
    headers={"X-API-Key": os.environ["DUALKEEP_API_KEY"]},
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/api/v1/rates?fiat=USD", nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", os.Getenv("DUALKEEP_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "fiat_currency": "USD",
  "rates": {
    "BTC": "60000.00",
    "ETH": "3000.00",
    "USDT_ERC20": "1.00"
  }
}

System

Health and liveness.

Liveness probe

GET /healthNo key

Lightweight, unauthenticated health check.

curl -X GET "https://dualkeep.com/health"
const res = await fetch("https://dualkeep.com/health", {
  method: "GET",
});

if (!res.ok) throw new Error(`DUALKEEP ${res.status}: ${await res.text()}`);
const data = await res.json();
import os, requests

res = requests.get(
    "https://dualkeep.com/health",
    timeout=30,
)
res.raise_for_status()
data = res.json()
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

func main() {
	req, err := http.NewRequest("GET", "https://dualkeep.com/health", nil)
	if err != nil {
		panic(err)
	}

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var data map[string]any
	if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
		panic(err)
	}
	fmt.Println(res.StatusCode, data)
}
200 OK
{
  "status": "ok"
}

Need a hand?

If something here does not match what the server does, that is a bug in the contract and we want to hear about it: [email protected], or the contact page.