/v3/fx/ surface. The worked example below sends USD → MXN and settles to a Mexican bank via SPEI; the same shape covers any pair × any fiat rail.
When to reach for this
- Paying suppliers, contractors, or payroll across borders
- Funding a beneficiary in their local currency on the same day
- Settling remittance flows where price needs to be confirmed before execution
Setup
Before the first cross-border payment, do these once per beneficiary:1
Verify the beneficiary's withdrawal account
Add the recipient’s bank account in the OpenFX
dashboard. The rail (SPEI, SEPA, Fed Wire, etc.) is
bound to the withdrawal account at creation time. See Verified
accounts for the full setup walkthrough.
2
Read it back via the API
Call
GET /v3/fx/withdrawal-accounts
and store the withdrawalAccountId. That UUID is the only thing your
withdrawal call needs — the rail and currency are inferred from the
withdrawal account.3
(Recommended) Subscribe to webhooks
Subscribe to the
withdrawals event so you don’t have to poll fiat rails
through banking hours — the terminal state lives in data.status
(COMPLETED or FAILED). See Webhooks setup.The flow
Step 1 — Quote the FX
POST /v3/fx/quotes returns a binding rate good for ~3 seconds (extendable via quoteForSeconds — standard durations 3, 15, 30, 45, 60). For cross-border, you usually owe the beneficiary a fixed destination amount — use buyAmount to anchor the destination side so the math comes out exact at settlement.
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": "MXN",
"sellCurrency": "USD",
"buyAmount": "50000.00"
}'
quote.id for the next step. quoteAmount carries the server-computed USD cost at the quoted rate. Both amounts come back as strings — see Amounts.
If your UI lets the user enter the source amount instead (e.g. “send
$1,000 USD”), anchor the source side: send
sellAmount: "1000.00" (and drop
buyAmount). The server computes destination-side MXN into buyAmount. The
trade and withdrawal steps don’t change.Step 2 — Execute the trade
POST /v3/fx/trades locks the rate in before expiresAt. Use a new Idempotency-Key — it’s a different logical operation than the quote.
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": "'"$QUOTE_ID"'" }'
status: "EXECUTED". Your USD balance has shifted into MXN. If you see QUOTE_EXPIRED, re-quote (Step 1) with a fresh key and try again — see the Trade error recovery rules.
Step 3 — Settle to the beneficiary
POST /v3/fx/withdrawals initiates settlement to the verified withdrawal account. You don’t pick the rail; SPEI is bound to the MXN withdrawal account.
cURL
curl -X POST https://api.openfx.com/v3/fx/withdrawals \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"withdrawalAccountId": "32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30",
"withdrawalAmount": "50000.00",
"currency": "MXN",
"metadata": { "invoiceId": "INV-12345" }
}'
withdrawalAmount: trade.buyAmount (the exact MXN credited by the trade) over reusing the user-input number. The two are equal when the quote was anchored to the destination side, but reading from the trade response insulates you from any future rounding or fee changes.
The response comes back with status: "PENDING". SPEI is 24/7 so MXN typically lands within minutes; other rails take longer. See Settlement times for the full cut-off matrix.
Step 4 — Confirm completion
Prefer the webhook over polling: awithdrawals event with data.status: "COMPLETED" arrives when funds have left OpenFX. If you must poll, use adaptive backoff — fixed 5-second polling burns rate-limit budget for nothing on a wire that may take hours.
curl https://api.openfx.com/v3/fx/withdrawals/$WITHDRAWAL_ID \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE"
COMPLETED (with a populated completedAt) or FAILED. See Withdrawal lifecycle.
Putting it together
// Quote -> trade -> withdraw to a beneficiary's bank account, anchored on
// the destination amount — the pattern from
// /v3/examples/cross-border-payments#putting-it-together.
// signRequest is the real helper from /v3/authentication — adjust this path
// if you've copied it somewhere else in your own project.
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, anchored on the destination
// (buyAmount) side — the beneficiary gets an exact amount. Kept separate
// from the network call so it's testable without a real request.
function buildQuoteBody(buyCurrency, sellCurrency, buyAmount) {
return { buyCurrency, sellCurrency, buyAmount };
}
// Builds the POST /v3/fx/withdrawals body. Kept separate from the network
// call so it's testable without a real request.
function buildWithdrawalBody(withdrawalAccountId, withdrawalAmount, currency, metadata) {
return { withdrawalAccountId, withdrawalAmount, currency, metadata };
}
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 json.data;
}
// A new Idempotency-Key per logical operation — quote, trade, and
// withdrawal are three distinct operations.
async function payBeneficiary({ withdrawalAccountId, mxnAmount, invoiceId }) {
const quote = await signedFetch("POST", `${BASE_URL}/quotes`, {
idempotencyKey: randomUUID(),
body: buildQuoteBody("MXN", "USD", mxnAmount), // anchor destination side
});
const trade = await signedFetch("POST", `${BASE_URL}/trades`, {
idempotencyKey: randomUUID(),
body: { quoteId: quote.id },
});
if (trade.status !== "EXECUTED") throw new Error(`Trade ${trade.status}`);
const withdrawal = await signedFetch("POST", `${BASE_URL}/withdrawals`, {
idempotencyKey: randomUUID(),
body: buildWithdrawalBody(withdrawalAccountId, mxnAmount, "MXN", { invoiceId }),
});
return { tradeId: trade.id, withdrawalId: withdrawal.id };
}
module.exports = { buildQuoteBody, buildWithdrawalBody, payBeneficiary };
if (require.main === module) {
payBeneficiary({
withdrawalAccountId: "32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30",
mxnAmount: "50000.00",
invoiceId: "INV-12345",
}).catch((err) => {
console.error("Error:", err.message);
process.exitCode = 1;
});
}
"""Quote -> trade -> withdraw to a beneficiary's bank account, anchored on
the destination amount — the pattern from
/v3/examples/cross-border-payments#putting-it-together."""
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, buy_amount):
"""Builds the POST /v3/fx/quotes body, anchored on the destination
(buy_amount) side — the beneficiary gets an exact amount. Kept separate
from the network call so it's testable without a real request."""
return {"buyCurrency": buy_currency, "sellCurrency": sell_currency, "buyAmount": buy_amount}
def build_withdrawal_body(withdrawal_account_id, withdrawal_amount, currency, metadata):
"""Builds the POST /v3/fx/withdrawals body. Kept separate from the
network call so it's testable without a real request."""
return {
"withdrawalAccountId": withdrawal_account_id,
"withdrawalAmount": withdrawal_amount,
"currency": currency,
"metadata": metadata,
}
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 pay_beneficiary(withdrawal_account_id, mxn_amount, invoice_id):
"""A new Idempotency-Key per logical operation — quote, trade, and
withdrawal are three distinct operations."""
quote = signed_request(
"POST",
f"{BASE_URL}/quotes",
idempotency_key=str(uuid.uuid4()),
body=build_quote_body("MXN", "USD", mxn_amount), # anchor destination side
)
trade = signed_request(
"POST", f"{BASE_URL}/trades", idempotency_key=str(uuid.uuid4()), body={"quoteId": quote["id"]}
)
if trade["status"] != "EXECUTED":
raise RuntimeError(f"Trade {trade['status']}")
withdrawal = signed_request(
"POST",
f"{BASE_URL}/withdrawals",
idempotency_key=str(uuid.uuid4()),
body=build_withdrawal_body(withdrawal_account_id, mxn_amount, "MXN", {"invoiceId": invoice_id}),
)
return {"tradeId": trade["id"], "withdrawalId": withdrawal["id"]}
if __name__ == "__main__":
print(pay_beneficiary("32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30", "50000.00", "INV-12345"))
// Quote -> trade -> withdraw to a beneficiary's bank account, anchored on
// the destination amount — the pattern from
// /v3/examples/cross-border-payments#putting-it-together. 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 PayBeneficiary {
static final String BASE_URL = "https://api.openfx.com/v3/fx";
// Builds the POST /v3/fx/quotes body, anchored on the destination
// (buyAmount) side — the beneficiary gets an exact amount. Kept
// separate from the network call so it's testable without a real
// request.
static Map<String, String> buildQuoteBody(String buyCurrency, String sellCurrency, String buyAmount) {
Map<String, String> body = new LinkedHashMap<>();
body.put("buyCurrency", buyCurrency);
body.put("sellCurrency", sellCurrency);
body.put("buyAmount", buyAmount);
return body;
}
// Builds the POST /v3/fx/withdrawals body, with a nested metadata
// object. Kept separate from the network call so it's testable
// without a real request.
static String buildWithdrawalBody(String withdrawalAccountId, String withdrawalAmount, String currency,
String invoiceId) {
return "{\"withdrawalAccountId\":\"" + escapeJson(withdrawalAccountId) + "\",\"withdrawalAmount\":\""
+ escapeJson(withdrawalAmount) + "\",\"currency\":\"" + escapeJson(currency)
+ "\",\"metadata\":{\"invoiceId\":\"" + escapeJson(invoiceId) + "\"}}";
}
// 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();
}
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);
}
// A new Idempotency-Key per logical operation — quote, trade, and
// withdrawal are three distinct operations.
static String[] payBeneficiary(String withdrawalAccountId, String mxnAmount, String invoiceId) throws Exception {
String quoteBody = toJsonObject(buildQuoteBody("MXN", "USD", mxnAmount)); // anchor destination side
String quoteResponse = signedRequest("POST", BASE_URL + "/quotes", UUID.randomUUID().toString(), quoteBody);
String quoteId = extractField(quoteResponse, "id");
if (quoteId == null) throw new RuntimeException("Quote response missing id: " + quoteResponse);
String tradeBody = "{\"quoteId\":\"" + escapeJson(quoteId) + "\"}";
String tradeResponse = signedRequest("POST", BASE_URL + "/trades", UUID.randomUUID().toString(), tradeBody);
String tradeStatus = extractField(tradeResponse, "status");
if (!"EXECUTED".equals(tradeStatus)) throw new RuntimeException("Trade " + tradeStatus);
String withdrawalBody = buildWithdrawalBody(withdrawalAccountId, mxnAmount, "MXN", invoiceId);
String withdrawalResponse = signedRequest(
"POST", BASE_URL + "/withdrawals", UUID.randomUUID().toString(), withdrawalBody);
return new String[] {extractField(tradeResponse, "id"), extractField(withdrawalResponse, "id")};
}
public static void main(String[] args) throws Exception {
String[] ids = payBeneficiary("32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30", "50000.00", "INV-12345");
System.out.println("tradeId=" + ids[0] + " withdrawalId=" + ids[1]);
}
}
// Quote -> trade -> withdraw to a beneficiary's bank account, anchored on
// the destination amount — the pattern from
// /v3/examples/cross-border-payments#putting-it-together. 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 <array>
#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, anchored on the destination
// (buyAmount) side — the beneficiary gets an exact amount. 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,
const std::string& buyAmount) {
return {{"buyCurrency", buyCurrency}, {"sellCurrency", sellCurrency}, {"buyAmount", buyAmount}};
}
// 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();
}
// Builds the POST /v3/fx/withdrawals body, with a nested metadata object.
// Kept separate from the network call so it's testable without a real
// request.
std::string buildWithdrawalBody(const std::string& withdrawalAccountId, const std::string& withdrawalAmount,
const std::string& currency, const std::string& invoiceId) {
return "{\"withdrawalAccountId\":\"" + escapeJson(withdrawalAccountId) + "\",\"withdrawalAmount\":\"" +
escapeJson(withdrawalAmount) + "\",\"currency\":\"" + escapeJson(currency) +
"\",\"metadata\":{\"invoiceId\":\"" + escapeJson(invoiceId) + "\"}}";
}
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;
}
// A new Idempotency-Key per logical operation — quote, trade, and
// withdrawal are three distinct operations.
std::array<std::string, 2> payBeneficiary(const std::string& withdrawalAccountId, const std::string& mxnAmount,
const std::string& invoiceId) {
std::string quoteBody = toJsonObject(buildQuoteBody("MXN", "USD", mxnAmount)); // anchor destination side
std::string quoteResponse = signedRequest("POST", "/quotes", randomIdempotencyKey(), quoteBody);
std::optional<std::string> quoteId = extractField(quoteResponse, "id");
if (!quoteId) throw std::runtime_error("Quote response missing id: " + quoteResponse);
std::string tradeBody = "{\"quoteId\":\"" + escapeJson(*quoteId) + "\"}";
std::string tradeResponse = signedRequest("POST", "/trades", randomIdempotencyKey(), tradeBody);
std::string tradeStatus = extractField(tradeResponse, "status").value_or("");
if (tradeStatus != "EXECUTED") throw std::runtime_error("Trade " + tradeStatus);
std::string withdrawalBody = buildWithdrawalBody(withdrawalAccountId, mxnAmount, "MXN", invoiceId);
std::string withdrawalResponse = signedRequest("POST", "/withdrawals", randomIdempotencyKey(), withdrawalBody);
return {extractField(tradeResponse, "id").value_or(""), extractField(withdrawalResponse, "id").value_or("")};
}
#ifndef PAY_BENEFICIARY_NO_MAIN
#include <iostream>
int main() {
try {
auto ids = payBeneficiary("32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30", "50000.00", "INV-12345");
std::cout << "tradeId=" << ids[0] << " withdrawalId=" << ids[1] << std::endl;
return 0;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}
#endif
// Quote -> trade -> withdraw to a beneficiary's bank account, anchored on
// the destination amount — the pattern from
// /v3/examples/cross-border-payments#putting-it-together.
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"`
BuyAmount string `json:"buyAmount"`
}
type WithdrawalBody struct {
WithdrawalAccountID string `json:"withdrawalAccountId"`
WithdrawalAmount string `json:"withdrawalAmount"`
Currency string `json:"currency"`
Metadata map[string]string `json:"metadata"`
}
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 Withdrawal struct {
ID string `json:"id"`
}
// Builds the POST /v3/fx/quotes body, anchored on the destination
// (BuyAmount) side — the beneficiary gets an exact amount. Kept separate
// from the network call so it's testable without a real request.
func buildQuoteBody(buyCurrency, sellCurrency, buyAmount string) QuoteBody {
return QuoteBody{BuyCurrency: buyCurrency, SellCurrency: sellCurrency, BuyAmount: buyAmount}
}
// Builds the POST /v3/fx/withdrawals body, with a nested Metadata map.
// Kept separate from the network call so it's testable without a real
// request.
func buildWithdrawalBody(withdrawalAccountID, withdrawalAmount, currency, invoiceID string) WithdrawalBody {
return WithdrawalBody{
WithdrawalAccountID: withdrawalAccountID,
WithdrawalAmount: withdrawalAmount,
Currency: currency,
Metadata: map[string]string{"invoiceId": invoiceID},
}
}
func newIdempotencyKey() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
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
}
// A new Idempotency-Key per logical operation — quote, trade, and
// withdrawal are three distinct operations.
func payBeneficiary(withdrawalAccountID, mxnAmount, invoiceID string) (tradeID, withdrawalID string, err error) {
quoteBody, err := json.Marshal(buildQuoteBody("MXN", "USD", mxnAmount)) // anchor destination side
if err != nil {
return "", "", err
}
key, err := newIdempotencyKey()
if err != nil {
return "", "", err
}
quoteData, err := signedRequest("POST", baseURL+"/quotes", key, quoteBody)
if err != nil {
return "", "", err
}
var quote Quote
if err := json.Unmarshal(quoteData, "e); err != nil {
return "", "", err
}
tradeBody, err := json.Marshal(map[string]string{"quoteId": quote.ID})
if err != nil {
return "", "", err
}
key, err = newIdempotencyKey()
if err != nil {
return "", "", err
}
tradeData, err := signedRequest("POST", baseURL+"/trades", key, tradeBody)
if err != nil {
return "", "", err
}
var trade Trade
if err := json.Unmarshal(tradeData, &trade); err != nil {
return "", "", err
}
if trade.Status != "EXECUTED" {
return "", "", fmt.Errorf("trade %s", trade.Status)
}
withdrawalBody, err := json.Marshal(buildWithdrawalBody(withdrawalAccountID, mxnAmount, "MXN", invoiceID))
if err != nil {
return "", "", err
}
key, err = newIdempotencyKey()
if err != nil {
return "", "", err
}
withdrawalData, err := signedRequest("POST", baseURL+"/withdrawals", key, withdrawalBody)
if err != nil {
return "", "", err
}
var withdrawal Withdrawal
if err := json.Unmarshal(withdrawalData, &withdrawal); err != nil {
return "", "", err
}
return trade.ID, withdrawal.ID, nil
}
func main() {
tradeID, withdrawalID, err := payBeneficiary("32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30", "50000.00", "INV-12345")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("tradeId=", tradeID, "withdrawalId=", withdrawalID)
}
#!/usr/bin/env bash
# Quote (anchored on the destination amount) -> trade -> withdraw to a
# beneficiary's bank account — the pattern from
# /v3/examples/cross-border-payments. 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. A new Idempotency-Key per logical
# operation — quote, trade, and withdrawal are three distinct operations.
set -euo pipefail
extract_field() { # $1: JSON, $2: field name
echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed -E "s/.*:\"([^\"]*)\"/\1/"
}
# 1. Quote USD -> MXN, anchored on the 50,000 MXN the beneficiary expects.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
QUOTE_RESPONSE=$(curl -s -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": "MXN",
"sellCurrency": "USD",
"buyAmount": "50000.00"
}')
QUOTE_ID=$(extract_field "$QUOTE_RESPONSE" "id")
# 2. Execute the trade against the quote.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
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": "'"$QUOTE_ID"'" }'
# 3. Settle to the beneficiary's verified bank account.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
WITHDRAWAL_RESPONSE=$(curl -s -X POST https://api.openfx.com/v3/fx/withdrawals \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"withdrawalAccountId": "32b8d4e1-7f56-4a39-b042-9c1e7d8f6a30",
"withdrawalAmount": "50000.00",
"currency": "MXN",
"metadata": { "invoiceId": "INV-12345" }
}')
WITHDRAWAL_ID=$(extract_field "$WITHDRAWAL_RESPONSE" "id")
# 4. Confirm completion.
export OPENFX_JWT="<jwt-from-signRequest>"
export OPENFX_SIGNATURE="<signature-from-signRequest>"
curl "https://api.openfx.com/v3/fx/withdrawals/$WITHDRAWAL_ID" \
-H "Authorization: Bearer $OPENFX_JWT" \
-H "X-Request-Signature: $OPENFX_SIGNATURE"
In Live, persist each idempotency key before issuing the request — see the
crash-recovery pattern. The function above
generates keys inline for brevity; a crash between the trade and the
withdrawal call would otherwise leave you unable to safely retry.
Common mistakes
- Reusing an Idempotency-Key across quote + trade + withdrawal. Each is a distinct logical operation. Generate a fresh UUID for each call or you’ll trip
422 IDEMPOTENCY_MISMATCH. - Quoting in the source currency when the beneficiary owes a fixed destination amount. If the invoice says “MXN 50,000”, anchor the destination:
buy: "MXN", buyAmount: "50000.00". Anchoring the USD side (viasellAmount) leaves you to absorb the rounding gap on conversion. - Submitting after a fiat rail’s daily cut-off. Watch Settlement times — a 5:15 PM ET SWIFT submission won’t dispatch until the next banking day.
- Polling fiat withdrawals every 5 seconds. Use webhooks or adaptive backoff. Fixed-interval polling on a SEPA wire just rate-limits you.
What’s next
Treasury management
Hold multi-currency balances, convert on demand without settling out.
Stablecoin on/off ramp
Fiat ↔ stablecoin with on-chain delivery.
Settlement times
Per-rail submission cut-offs.
Withdrawal lifecycle
pending → processing → completed.