Before you start
- An OpenFX API key — generate one from the dashboard and download the JSON file (contains
nameandprivateKey). See Authentication for the file format and how to sign tokens. - Node.js, Python, or any HTTP client (cURL works fine)
- About 5 minutes
Coming from v2? The high-level flow is unchanged: pairs → quote → trade →
balances. The wire contract changes materially: URL prefix, response envelope,
amount types, quote request shape, pagination, and error handling. Use
Migration from v2 for the full delta.
1. Mint a JWT bearer token
Every request to/v3/fx/* carries an Authorization: Bearer <jwt> header and an X-Request-Signature ES256 signature over the request. JWTs are ES256-signed, single-use, and expire 60 seconds after issuance. Mint both together, per request, with the signRequest helper — see Mint a JWT and sign the request for the implementation in Node, Python, Java, C++, and Go.
The cURL snippets below carry only
Authorization — they omit
X-Request-Signature because cURL alone can’t compute an ES256 signature.
They illustrate the wire shape, not a request the API will accept as-is;
copy-pasting one verbatim returns 401 AUTH_TOKEN_INVALID. For a request the
API actually accepts, call signRequest from
Authentication, or run
Putting it together below.# Illustrative only — see the warning above. In real code, call signRequest
# per request instead of exporting one token for the whole session.
export OPENFX_JWT="<jwt-from-signRequest>"
JWTs are single-use (server-enforced nonce uniqueness) and have a 60-second
TTL. Copy-pasting one twice is rejected with
401 AUTH_TOKEN_INVALID
(details.reason: REPLAYED). Re-mint between every example below. See
Authentication for the helper and signing flow.The
$OPENFX_JWT env var below is reused across steps for readability — production code mints and signs per request. The linear walkthrough (pairs → quote → trade → balances) reads more naturally with one exported token, but any sequence that includes user think-time, retries, polling, or pagination must mint a fresh JWT and a fresh X-Request-Signature per HTTP call. The canonical pattern is in Rate limiting → Handling rate limits pattern; every retry/poll/pagination sample elsewhere in v3 takes a signRequest({ method, url, body }) => { headers, body } parameter rather than a captured token. Putting it together below shows the real per-call version, in every supported language.2. Verify connectivity
HitGET /v3/fx/pairs (authenticated, no body). If your JWT is good, you’ll get back the list of tradable pairs. This is the smoke test that confirms key, signing, and network path all work end-to-end.
cURL
curl https://api.openfx.com/v3/fx/pairs \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-D headers.txt
# `headers.txt` now contains X-Trace-Id and the RateLimit-* headers.
Pair resources nested under data.pairs. The actual response wraps the object below in data; the PairList shape itself is:
{
"pairs": [
{
"buyCurrency": "USDC",
"sellCurrency": "USD",
"minTradeAmount": "10.00",
"maxTradeAmount": "1000000.00"
},
{
"buyCurrency": "USDT",
"sellCurrency": "EUR",
"minTradeAmount": "10.00",
"maxTradeAmount": "1000000.00"
}
]
}
Every response carries
X-Trace-Id and RateLimit-* in the headers. Capture
X-Trace-Id on every response (success too) so support can reconstruct the
request later. See Metadata & tracing.3. Quote a trade
POST /v3/fx/quotes returns a price guaranteed until expiresAt (default 3 seconds). Quotes are a separate step from execution so you can show the user a rate, get confirmation, and execute without surprising them on the price.
State-changing operations need an Idempotency-Key header: a client-generated string that lets you safely retry the call. Generate one UUID v4 per logical operation. See Idempotency.
Pick which side of the trade you want to anchor: supply sellAmount if you want to spend an exact amount, or buyAmount if you need to receive an exact amount. Supply exactly one — the server computes the other side at the quoted rate. This example anchors the sell side (1000 USD out).
cURL
curl -X POST https://api.openfx.com/v3/fx/quotes \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"buyCurrency": "USDC",
"sellCurrency": "USD",
"sellAmount": "1000.00"
}'
Quote in data, alongside metadata. The Quote resource itself is:
{
"object": "quote",
"id": "qte_3FfGK34vwMvVFDedyb2nkf",
"buyCurrency": "USDC",
"sellCurrency": "USD",
"sellAmount": "1000.00",
"quoteAmount": "999.50",
"settlementWindow": "T0",
"expiresAt": "2026-04-28T10:00:03.000Z",
"createdAt": "2026-04-28T10:00:00.000Z"
}
id; you’ll pass it to the next step. sellAmount is exact ($1000.00) and quoteAmount is the server-computed counter-leg. Notice both amounts are strings, not JSON numbers; see Amounts.
4. Execute the trade
POST /v3/fx/trades executes against the quote ID. You have until expiresAt (about 3 seconds from the quote’s createdAt); after that, the call returns 409 QUOTE_EXPIRED and you go back to step 3 for a fresh price.
cURL
curl -X POST https://api.openfx.com/v3/fx/trades \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "quoteId": "qte_3FfGK34vwMvVFDedyb2nkf" }'
Trade in data, with status: "EXECUTED". The Trade resource itself is:
{
"object": "trade",
"id": "tde_5W7guYdHT24JFnRQrZN9y8",
"quoteId": "qte_3FfGK34vwMvVFDedyb2nkf",
"status": "EXECUTED",
"buyCurrency": "USDC",
"sellCurrency": "USD",
"executedAmount": "999.50",
"clientReferenceId": null,
"createdAt": "2026-04-28T10:00:00.000Z",
"settlementWindow": "T0",
"settlementStartTime": null
}
One
Idempotency-Key per logical operation. The quote and the trade are
two operations. Generate two keys. Reusing the same key for two different
trades returns 422 IDEMPOTENCY_MISMATCH. See Idempotency.5. Check your balance
GET /v3/fx/balances returns a Balance per currency. The USDC you just bought should show up in availableBalance. In v2 the trade response bundled balances; in v3 you fetch them separately.
cURL
curl https://api.openfx.com/v3/fx/balances \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE"
{
"data": [
{
"currency": "USD",
"availableBalance": "4000.00",
"totalBalance": "4150.00"
},
{
"currency": "USDC",
"availableBalance": "999.500000",
"totalBalance": "999.500000"
}
],
"pagination": {
"limit": 25,
"hasNext": false,
"nextCursor": null,
"hasPrev": false,
"prevCursor": null
}
}
5150.00, the trade above debited 1000.00 USD and credited 999.50 USDC, leaving 4150.00 USD total. Your own balances will differ from your trade history.
That’s the full round-trip: pairs → quote → trade → balances. The same shape and headers apply to every other v3 endpoint.
Putting it together
The complete round-trip, realsignRequest and all — mint a fresh JWT and signature per call, no reused jwt symbol:
// The full quickstart round-trip: pairs -> quote -> trade -> balances — the
// pattern from /v3/quickstart. signRequest is the real helper from
// /v3/authentication — adjust this path if you've copied it somewhere else.
const { randomUUID } = require("node:crypto");
const { signRequest } = require("../../authentication/node/sign-request");
const BASE_URL = "https://api.openfx.com/v3/fx";
// Builds the POST /v3/fx/quotes body. Supply exactly one of sellAmount or
// buyAmount to anchor that side. Kept separate from the network call so
// it's testable without a real request.
function buildQuoteBody({ buyCurrency, sellCurrency, sellAmount, buyAmount }) {
const body = { buyCurrency, sellCurrency };
if (sellAmount !== undefined) body.sellAmount = sellAmount;
if (buyAmount !== undefined) body.buyAmount = buyAmount;
return body;
}
// Builds the POST /v3/fx/trades body. Kept separate from the network call
// so it's testable without a real request.
function buildTradeBody(quoteId) {
return { quoteId };
}
async function signedFetch(method, url, { idempotencyKey, body } = {}) {
const bodyStr = body ? JSON.stringify(body) : "";
const { headers } = signRequest({ method, url, body: bodyStr });
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(url, { method, headers, body: body ? bodyStr : undefined });
const json = await res.json();
return { res, data: json.data };
}
async function listPairs() {
const { data } = await signedFetch("GET", `${BASE_URL}/pairs`);
return data.pairs;
}
// A new Idempotency-Key per logical operation — reusing one across a quote
// and a trade returns 422 IDEMPOTENCY_MISMATCH.
async function quote(params) {
const { data } = await signedFetch("POST", `${BASE_URL}/quotes`, {
idempotencyKey: randomUUID(),
body: buildQuoteBody(params),
});
return data;
}
async function executeTrade(quoteId) {
const { data } = await signedFetch("POST", `${BASE_URL}/trades`, {
idempotencyKey: randomUUID(),
body: buildTradeBody(quoteId),
});
return data;
}
async function getBalances() {
const { data } = await signedFetch("GET", `${BASE_URL}/balances`);
return data;
}
async function quickstart() {
const pairs = await listPairs();
console.log({ step: "pairs", count: pairs.length });
const quoted = await quote({ buyCurrency: "USDC", sellCurrency: "USD", sellAmount: "1000.00" });
console.log({ step: "quote", quoteId: quoted.id });
const trade = await executeTrade(quoted.id);
console.log({ step: "trade", tradeId: trade.id, status: trade.status });
const balances = await getBalances();
console.log({ step: "balances", balances });
}
module.exports = { buildQuoteBody, buildTradeBody, listPairs, quote, executeTrade, getBalances, quickstart };
if (require.main === module) {
quickstart().catch((err) => {
console.error("Error:", err.message);
process.exitCode = 1;
});
}
"""The full quickstart round-trip: pairs -> quote -> trade -> balances — the
pattern from /v3/quickstart."""
import json
import os
import sys
import uuid
# sign_request is the real helper from /v3/authentication — adjust this path
# if you've copied it somewhere else in your own project.
sys.path.insert(
0, os.path.join(os.path.dirname(__file__), "..", "..", "authentication", "python")
)
from sign_request import sign_request # noqa: E402
import requests # noqa: E402
BASE_URL = "https://api.openfx.com/v3/fx"
def build_quote_body(buy_currency, sell_currency, sell_amount=None, buy_amount=None):
"""Builds the POST /v3/fx/quotes body. Supply exactly one of sell_amount
or buy_amount to anchor that side. Kept separate from the network call
so it's testable without a real request."""
body = {"buyCurrency": buy_currency, "sellCurrency": sell_currency}
if sell_amount is not None:
body["sellAmount"] = sell_amount
if buy_amount is not None:
body["buyAmount"] = buy_amount
return body
def build_trade_body(quote_id):
"""Builds the POST /v3/fx/trades body. Kept separate from the network
call so it's testable without a real request."""
return {"quoteId": quote_id}
def signed_request(method, url, idempotency_key=None, body=None):
body_bytes = json.dumps(body).encode("utf-8") if body is not None else b""
headers, _ = sign_request(method, url, body_bytes)
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
res = requests.request(method, url, headers=headers, data=body_bytes if body else None)
return res.json()["data"]
def list_pairs():
return signed_request("GET", f"{BASE_URL}/pairs")["pairs"]
def quote(buy_currency, sell_currency, sell_amount=None, buy_amount=None):
# A new Idempotency-Key per logical operation — reusing one across a
# quote and a trade returns 422 IDEMPOTENCY_MISMATCH.
return signed_request(
"POST",
f"{BASE_URL}/quotes",
idempotency_key=str(uuid.uuid4()),
body=build_quote_body(buy_currency, sell_currency, sell_amount, buy_amount),
)
def execute_trade(quote_id):
return signed_request(
"POST",
f"{BASE_URL}/trades",
idempotency_key=str(uuid.uuid4()),
body=build_trade_body(quote_id),
)
def get_balances():
return signed_request("GET", f"{BASE_URL}/balances")
def quickstart():
pairs = list_pairs()
print({"step": "pairs", "count": len(pairs)})
quoted = quote("USDC", "USD", sell_amount="1000.00")
print({"step": "quote", "quoteId": quoted["id"]})
trade = execute_trade(quoted["id"])
print({"step": "trade", "tradeId": trade["id"], "status": trade["status"]})
balances = get_balances()
print({"step": "balances", "balances": balances})
if __name__ == "__main__":
quickstart()
// The full quickstart round-trip: pairs -> quote -> trade -> balances — the
// pattern from /v3/quickstart. Uses only the built-in JDK APIs — no
// external dependency, so JSON handling below is intentionally minimal
// (a flat object serializer and single-field extractors), not a general
// JSON parser.
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Quickstart {
static final String BASE_URL = "https://api.openfx.com/v3/fx";
// Builds the POST /v3/fx/quotes body. Supply exactly one of sellAmount
// or buyAmount to anchor that side. Kept separate from the network call
// so it's testable without a real request.
static Map<String, String> buildQuoteBody(String buyCurrency, String sellCurrency, String sellAmount, String buyAmount) {
Map<String, String> body = new LinkedHashMap<>();
body.put("buyCurrency", buyCurrency);
body.put("sellCurrency", sellCurrency);
if (sellAmount != null) body.put("sellAmount", sellAmount);
if (buyAmount != null) body.put("buyAmount", buyAmount);
return body;
}
// Builds the POST /v3/fx/trades body. Kept separate from the network
// call so it's testable without a real request.
static Map<String, String> buildTradeBody(String quoteId) {
Map<String, String> body = new LinkedHashMap<>();
body.put("quoteId", quoteId);
return body;
}
// Escapes a string for embedding in a JSON string literal. Sufficient
// for this page's field values; not a general JSON serializer.
static String escapeJson(String value) {
StringBuilder out = new StringBuilder();
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"': out.append("\\\""); break;
case '\\': out.append("\\\\"); break;
case '\n': out.append("\\n"); break;
case '\r': out.append("\\r"); break;
case '\t': out.append("\\t"); break;
default:
if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
}
return out.toString();
}
// Serializes a flat string-valued map to a JSON object. Sufficient for
// this page's request bodies; not a general JSON serializer.
static String toJsonObject(Map<String, String> fields) {
StringBuilder json = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, String> entry : fields.entrySet()) {
if (!first) json.append(",");
first = false;
json.append("\"").append(entry.getKey()).append("\":\"").append(escapeJson(entry.getValue())).append("\"");
}
return json.append("}").toString();
}
// Extracts one top-level string field from a JSON object by regex.
// Sufficient for this page's response shapes; not a general JSON parser.
static String extractField(String json, String field) {
Matcher m = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
static String signedRequest(String method, String url, String idempotencyKey, String body) throws Exception {
byte[] bodyBytes = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
// SignRequest is the real helper from /v3/authentication, compiled
// on the classpath alongside this file — see that page's example.
Map<String, String> headers = SignRequest.signRequest(method, url, bodyBytes);
if (idempotencyKey != null) headers.put("Idempotency-Key", idempotencyKey);
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setRequestMethod(method);
for (Map.Entry<String, String> h : headers.entrySet()) {
conn.setRequestProperty(h.getKey(), h.getValue());
}
if (body != null) {
conn.setDoOutput(true);
conn.getOutputStream().write(bodyBytes);
}
int status = conn.getResponseCode();
InputStream responseStream = status < 400 ? conn.getInputStream() : conn.getErrorStream();
// Reads the full response body. extractField below does minimal
// single-field extraction from it, not a general JSON parser.
return responseStream == null ? "" : new String(responseStream.readAllBytes(), StandardCharsets.UTF_8);
}
static String listPairs() throws Exception {
return signedRequest("GET", BASE_URL + "/pairs", null, null);
}
// A new Idempotency-Key per logical operation — reusing one across a
// quote and a trade returns 422 IDEMPOTENCY_MISMATCH.
static String quote(String buyCurrency, String sellCurrency, String sellAmount, String buyAmount) throws Exception {
String body = toJsonObject(buildQuoteBody(buyCurrency, sellCurrency, sellAmount, buyAmount));
return signedRequest("POST", BASE_URL + "/quotes", UUID.randomUUID().toString(), body);
}
static String executeTrade(String quoteId) throws Exception {
String body = toJsonObject(buildTradeBody(quoteId));
return signedRequest("POST", BASE_URL + "/trades", UUID.randomUUID().toString(), body);
}
static String getBalances() throws Exception {
return signedRequest("GET", BASE_URL + "/balances", null, null);
}
public static void main(String[] args) throws Exception {
listPairs();
System.out.println("step=pairs");
String quoteResponse = quote("USDC", "USD", "1000.00", null);
String quoteId = extractField(quoteResponse, "id");
if (quoteId == null) throw new RuntimeException("Quote response missing id: " + quoteResponse);
System.out.println("step=quote quoteId=" + quoteId);
String tradeResponse = executeTrade(quoteId);
System.out.println("step=trade tradeId=" + extractField(tradeResponse, "id")
+ " status=" + extractField(tradeResponse, "status"));
getBalances();
System.out.println("step=balances");
}
}
// The full quickstart round-trip: pairs -> quote -> trade -> balances — the
// pattern from /v3/quickstart. Requires OpenSSL and libcurl. No JSON
// library was added, so JSON handling below is intentionally minimal (a
// flat object serializer and single-field extractors), not a general
// JSON parser.
#include <curl/curl.h>
#include <openssl/rand.h>
#include <iomanip>
#include <map>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
// Matches the definition in ../../authentication/cpp/sign_request.cpp,
// compiled together with this file — see that file for the real
// implementation of signRequest.
struct SignedRequest {
std::string authHeader;
std::string sigHeader;
};
SignedRequest signRequest(const std::string& method, const std::string& urlPath,
const std::string& query, const std::string& body);
static const std::string BASE_URL = "https://api.openfx.com/v3/fx";
// Builds the POST /v3/fx/quotes body. Supply exactly one of sellAmount or
// buyAmount to anchor that side. Kept separate from the network call so
// it's testable without a real request.
std::map<std::string, std::string> buildQuoteBody(const std::string& buyCurrency, const std::string& sellCurrency,
std::optional<std::string> sellAmount,
std::optional<std::string> buyAmount) {
std::map<std::string, std::string> body{{"buyCurrency", buyCurrency}, {"sellCurrency", sellCurrency}};
if (sellAmount) body["sellAmount"] = *sellAmount;
if (buyAmount) body["buyAmount"] = *buyAmount;
return body;
}
// Builds the POST /v3/fx/trades body. Kept separate from the network call
// so it's testable without a real request.
std::map<std::string, std::string> buildTradeBody(const std::string& quoteId) {
return {{"quoteId", quoteId}};
}
// Escapes a string for embedding in a JSON string literal. Sufficient for
// this page's field values; not a general JSON serializer.
std::string escapeJson(const std::string& value) {
std::ostringstream out;
for (unsigned char c : value) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default:
if (c < 0x20) out << "\\u" << std::hex << std::setfill('0') << std::setw(4) << (int)c << std::dec;
else out << (char)c;
}
}
return out.str();
}
// Serializes a flat string-valued map to a JSON object. Sufficient for
// this page's request bodies; not a general JSON serializer.
std::string toJsonObject(const std::map<std::string, std::string>& fields) {
std::ostringstream json;
json << "{";
bool first = true;
for (const auto& [key, value] : fields) {
if (!first) json << ",";
first = false;
json << "\"" << key << "\":\"" << escapeJson(value) << "\"";
}
json << "}";
return json.str();
}
// Generates a random per-call idempotency key. Not RFC 4122 UUID format —
// idempotency keys only need to be unique client-generated strings — but
// uses the same RAND_bytes source as the JWT nonce in sign_request.cpp.
std::string randomIdempotencyKey() {
unsigned char bytes[16];
RAND_bytes(bytes, sizeof(bytes));
std::ostringstream hex;
for (unsigned char b : bytes) hex << std::hex << std::setfill('0') << std::setw(2) << (int)b;
return hex.str();
}
// Extracts one top-level string field from a JSON object with a naive scan.
// Sufficient for this page's response shapes; not a general JSON parser.
std::optional<std::string> extractField(const std::string& json, const std::string& field) {
std::string needle = "\"" + field + "\":\"";
size_t start = json.find(needle);
if (start == std::string::npos) return std::nullopt;
start += needle.size();
size_t end = json.find('"', start);
if (end == std::string::npos) return std::nullopt;
return json.substr(start, end - start);
}
static size_t writeToString(char* data, size_t size, size_t nmemb, void* userp) {
static_cast<std::string*>(userp)->append(data, size * nmemb);
return size * nmemb;
}
std::string signedRequest(const std::string& method, const std::string& path, const std::string& idempotencyKey,
const std::string& body) {
std::string url = BASE_URL + path;
SignedRequest req = signRequest(method, "/v3/fx" + path, "", body);
CURL* curl = curl_easy_init();
curl_slist* headers = nullptr;
headers = curl_slist_append(headers, ("Authorization: " + req.authHeader).c_str());
headers = curl_slist_append(headers, ("X-Request-Signature: " + req.sigHeader).c_str());
if (!idempotencyKey.empty()) headers = curl_slist_append(headers, ("Idempotency-Key: " + idempotencyKey).c_str());
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
if (!body.empty()) curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
std::string responseBody;
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeToString);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseBody);
curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
// Returns the raw response body. extractField above does minimal
// single-field extraction from it, not a general JSON parser.
return responseBody;
}
std::string listPairs() {
return signedRequest("GET", "/pairs", "", "");
}
// A new Idempotency-Key per logical operation — reusing one across a quote
// and a trade returns 422 IDEMPOTENCY_MISMATCH.
std::string quote(const std::string& buyCurrency, const std::string& sellCurrency,
std::optional<std::string> sellAmount, std::optional<std::string> buyAmount) {
std::string body = toJsonObject(buildQuoteBody(buyCurrency, sellCurrency, sellAmount, buyAmount));
return signedRequest("POST", "/quotes", randomIdempotencyKey(), body);
}
std::string executeTrade(const std::string& quoteId) {
std::string body = toJsonObject(buildTradeBody(quoteId));
return signedRequest("POST", "/trades", randomIdempotencyKey(), body);
}
std::string getBalances() {
return signedRequest("GET", "/balances", "", "");
}
#ifndef QUICKSTART_NO_MAIN
#include <iostream>
int main() {
try {
listPairs();
std::cout << "step=pairs" << std::endl;
std::string quoteResponse = quote("USDC", "USD", "1000.00", std::nullopt);
std::optional<std::string> quoteIdOpt = extractField(quoteResponse, "id");
if (!quoteIdOpt) throw std::runtime_error("Quote response missing id: " + quoteResponse);
std::string quoteId = *quoteIdOpt;
std::cout << "step=quote quoteId=" << quoteId << std::endl;
std::string tradeResponse = executeTrade(quoteId);
std::cout << "step=trade tradeId=" << extractField(tradeResponse, "id").value_or("")
<< " status=" << extractField(tradeResponse, "status").value_or("") << std::endl;
getBalances();
std::cout << "step=balances" << std::endl;
return 0;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}
#endif
// The full quickstart round-trip: pairs -> quote -> trade -> balances — the
// pattern from /v3/quickstart.
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
// auth is the real signRequest helper from /v3/authentication — see
// go.mod's replace directive if you've copied it somewhere else.
auth "openfx-v3-authentication-example"
)
const baseURL = "https://api.openfx.com/v3/fx"
type QuoteBody struct {
BuyCurrency string `json:"buyCurrency"`
SellCurrency string `json:"sellCurrency"`
SellAmount string `json:"sellAmount,omitempty"`
BuyAmount string `json:"buyAmount,omitempty"`
}
type TradeBody struct {
QuoteID string `json:"quoteId"`
}
type apiEnvelope struct {
Data json.RawMessage `json:"data"`
}
type Quote struct {
ID string `json:"id"`
}
type Trade struct {
ID string `json:"id"`
Status string `json:"status"`
}
type PairList struct {
Pairs []json.RawMessage `json:"pairs"`
}
// Builds the POST /v3/fx/quotes body. Supply exactly one of sellAmount or
// buyAmount to anchor that side. Kept separate from the network call so
// it's testable without a real request.
func buildQuoteBody(buyCurrency, sellCurrency, sellAmount, buyAmount string) QuoteBody {
return QuoteBody{BuyCurrency: buyCurrency, SellCurrency: sellCurrency, SellAmount: sellAmount, BuyAmount: buyAmount}
}
// Builds the POST /v3/fx/trades body. Kept separate from the network call
// so it's testable without a real request.
func buildTradeBody(quoteID string) TradeBody {
return TradeBody{QuoteID: quoteID}
}
func newIdempotencyKey() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func signedRequest(method, url, idempotencyKey string, body []byte) (json.RawMessage, error) {
signed, err := auth.SignRequest(method, url, body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
for k, v := range signed.Headers {
req.Header.Set(k, v)
}
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var envelope apiEnvelope
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
return nil, err
}
return envelope.Data, nil
}
func listPairs() (*PairList, error) {
data, err := signedRequest("GET", baseURL+"/pairs", "", nil)
if err != nil {
return nil, err
}
var pairs PairList
if err := json.Unmarshal(data, &pairs); err != nil {
return nil, err
}
return &pairs, nil
}
// A new Idempotency-Key per logical operation — reusing one across a quote
// and a trade returns 422 IDEMPOTENCY_MISMATCH.
func quote(buyCurrency, sellCurrency, sellAmount, buyAmount string) (*Quote, error) {
body, err := json.Marshal(buildQuoteBody(buyCurrency, sellCurrency, sellAmount, buyAmount))
if err != nil {
return nil, err
}
key, err := newIdempotencyKey()
if err != nil {
return nil, err
}
data, err := signedRequest("POST", baseURL+"/quotes", key, body)
if err != nil {
return nil, err
}
var q Quote
if err := json.Unmarshal(data, &q); err != nil {
return nil, err
}
return &q, nil
}
func executeTrade(quoteID string) (*Trade, error) {
body, err := json.Marshal(buildTradeBody(quoteID))
if err != nil {
return nil, err
}
key, err := newIdempotencyKey()
if err != nil {
return nil, err
}
data, err := signedRequest("POST", baseURL+"/trades", key, body)
if err != nil {
return nil, err
}
var t Trade
if err := json.Unmarshal(data, &t); err != nil {
return nil, err
}
return &t, nil
}
func getBalances() (json.RawMessage, error) {
return signedRequest("GET", baseURL+"/balances", "", nil)
}
func main() {
pairs, err := listPairs()
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("step=pairs count=", len(pairs.Pairs))
q, err := quote("USDC", "USD", "1000.00", "")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("step=quote quoteId=", q.ID)
t, err := executeTrade(q.ID)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("step=trade tradeId=", t.ID, "status=", t.Status)
if _, err := getBalances(); err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("step=balances")
}
#!/usr/bin/env bash
# The full quickstart round-trip: pairs -> quote -> trade -> balances — the
# pattern from /v3/quickstart. Illustrative only: cURL can't compute the
# ES256 request signature itself, so $OPENFX_JWT/$OPENFX_SIGNATURE below are
# placeholders — get real values from signRequest, in any supported
# language, via /v3/authentication#mint-a-jwt-and-sign-the-request. Mint a
# fresh pair per call; never reuse one across requests.
set -euo pipefail
BASE_URL="https://api.openfx.com/v3/fx"
# Extracts one top-level string field from a JSON response by pattern match.
# Sufficient for this script's response shapes; not a general JSON parser.
extract_field() {
echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed -E "s/.*:\"([^\"]*)\"/\1/"
}
# 1. List pairs.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
curl "$BASE_URL/pairs" \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE"
# 2. Quote 1000 USD -> USDC. A new Idempotency-Key per logical operation —
# reusing one across a quote and a trade returns 422 IDEMPOTENCY_MISMATCH.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
QUOTE_RESPONSE=$(curl -s -X POST "$BASE_URL/quotes" \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"buyCurrency": "USDC",
"sellCurrency": "USD",
"sellAmount": "1000.00"
}')
QUOTE_ID=$(extract_field "$QUOTE_RESPONSE" "id")
# 3. Execute the trade against the quote.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
TRADE_RESPONSE=$(curl -s -X POST "$BASE_URL/trades" \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "quoteId": "'"$QUOTE_ID"'" }')
echo "trade status: $(extract_field "$TRADE_RESPONSE" "status")"
# 4. Check the resulting balances.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
curl "$BASE_URL/balances" \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE"
What’s next
Resource IDs
Readable, typed-prefix ID format, stability guarantees, and cross-resource
references.
Errors
Typed error codes, retryable flags, and the catch-and-branch pattern for
your next 5xx.
Trade Settlement
Deposits, withdrawals, and the unified fiat + stablecoin endpoint.
API reference
Every endpoint, every field, every response shape.
Live checklist
Pre-launch checklist, key rotation, IP allowlisting.