Signing Payload Format

Every request to the OpenFX API must include an Ed25519 signature computed over a signing payload — a deterministic string built from four components of the request, joined by newline characters:
HTTP_VERB + "\n" + REQUEST_PATH + "\n" + TIMESTAMP + "\n" + BODY
ComponentDescriptionExample
HTTP_VERBUppercase HTTP methodGET, POST, PATCH, DELETE
REQUEST_PATHFull path including query string, without host/v1/entities?limit=10
TIMESTAMPUnix timestamp in seconds (same value sent in X-Timestamp)1740500000
BODYRaw JSON request body, or empty string for requests with no body{"type":"individual","fullName":"Jane Doe"}
The REQUEST_PATH includes the query string if present. For POST requests to /v1/payments, the path is simply /v1/payments. For GET requests with query parameters like /v1/entities?limit=10&starting_after=ent_01953e1a, the entire path plus query string is included.

Step-by-Step Process

Follow these six steps for every API request:

Step 1: Build the Signing Payload

Concatenate the four components with newline (\n) separators:
POST\n/v1/payments\n1740500000\n{"sourceAccountId":"acc_01953e1a","value": "150000","currency":"USD"}
For GET requests with no body, the payload ends with an empty string after the final newline:
GET\n/v1/entities?limit=10\n1740500000\n
The request body in the signing payload must be the exact bytes sent in the HTTP request body. If you serialize JSON, sign it, and then re-serialize before sending, the signatures will not match. Sign the final serialized form.

Step 2: Sign with Your Ed25519 Private Key

Use your Ed25519 private key to sign the UTF-8 encoded bytes of the signing payload. Ed25519 produces a deterministic 64-byte signature.

Step 3: Base64-Encode the Signature

Encode the 64-byte raw signature using standard Base64 encoding (not URL-safe Base64).

Step 4: Set the X-Signature Header

Place the Base64-encoded signature in the X-Signature request header.

Step 5: Set the X-Timestamp Header

Set X-Timestamp to the same Unix timestamp (seconds) used in the signing payload.

Step 6: Set the Authorization Header

Include your API key as a Bearer token: Authorization: Bearer <api_key>.

Complete Code Examples

GET Request (No Body)

Fetching a list of entities — the signing payload has an empty body component.
#!/bin/bash

# Configuration
API_KEY="ofx_sk_sandbox_abc123def456"
PRIVATE_KEY_FILE="openfx_ed25519.pem"

# Request details
METHOD="GET"
PATH="/v1/entities?limit=10"
TIMESTAMP=$(date +%s)
BODY=""

# Step 1: Build signing payload
SIGNING_PAYLOAD="${METHOD}\n${PATH}\n${TIMESTAMP}\n${BODY}"

# Step 2-3: Sign and Base64-encode
SIGNATURE=$(printf "${SIGNING_PAYLOAD}" \
  | openssl pkeyutl -sign -inkey "${PRIVATE_KEY_FILE}" \
  | base64)

# Steps 4-6: Send request with all three headers
curl -X GET "https://sandbox.api.openfx.com${PATH}" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "X-Signature: ${SIGNATURE}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "Content-Type: application/json"

POST Request (With JSON Body)

Creating a payment — the signing payload includes the full JSON body.
#!/bin/bash

# Configuration
API_KEY="ofx_sk_sandbox_abc123def456"
PRIVATE_KEY_FILE="openfx_ed25519.pem"

# Request details
METHOD="POST"
PATH="/v1/payments"
TIMESTAMP=$(date +%s)
BODY='{"sourceAccountId":"acc_01953e1a5f4b7001","value": "150000","currency":"USD","rail":"ach","counterpartyId":"cpt_01953e1a5f4b7002","paymentMethodId":"pm_01953e1a5f4b7003","description":"Invoice #1042"}'

# Step 1: Build signing payload
SIGNING_PAYLOAD="${METHOD}\n${PATH}\n${TIMESTAMP}\n${BODY}"

# Step 2-3: Sign and Base64-encode
SIGNATURE=$(printf "${SIGNING_PAYLOAD}" \
  | openssl pkeyutl -sign -inkey "${PRIVATE_KEY_FILE}" \
  | base64)

# Steps 4-6: Send request with all three headers
curl -X POST "https://sandbox.api.openfx.com${PATH}" \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "X-Signature: ${SIGNATURE}" \
  -H "X-Timestamp: ${TIMESTAMP}" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "${BODY}"

Signing Payload Examples

To make the signing payload construction concrete, here are exact payload strings for common request types:

GET with Query Parameters

GET
/v1/accounts?limit=25&starting_after=acc_01953e1a5f4b7001
1740500000

Note the trailing newline followed by an empty string. For GET requests, the body component is empty, but the newline separator before it is still present.

POST with JSON Body

POST
/v1/fx/quotes
1740500000
{"sellCurrency":"USD","buyCurrency":"EUR","sellAmount":"10000.00","accountId":"acc_01953e1a5f4b7001"}

PATCH with Partial Update

PATCH
/v1/counterparties/cpt_01953e1a5f4b7002
1740500000
{"displayName":"Acme Corp - London Office"}

DELETE (No Body)

DELETE
/v1/counterparties/cpt_01953e1a5f4b7002/payment-methods/pm_01953e1a5f4b7003
1740500000

Timestamp Tolerance

The OpenFX server rejects any request where the X-Timestamp value differs from the server’s current time by more than 60 seconds in either direction.
Server time:  1740500030
                 |
  [-60s]=========|=========[+60s]
  1739999970     |     1740500090
                 |
  Valid window: 1739999970 to 1740500090
If your request arrives outside this window, you will receive:
{
  "error": {
    "type": "authentication_error",
    "code": "timestamp_out_of_range",
    "message": "The X-Timestamp header value is outside the acceptable tolerance window of +/-60 seconds.",
    "status": 401,
    "requestId": "req_01953e1a5f4b7def",
    "retryable": true
  }
}
The retryable: true flag on timestamp errors means you can immediately retry with a fresh timestamp. If you see these errors consistently, synchronize your server clock with NTP.

Common Pitfalls

1. Body Mismatch Between Signing and Sending

The most common signing failure is signing one serialization of the JSON body but sending a different one. This happens when you:
  • Use a language’s json= parameter (which re-serializes) instead of sending the pre-serialized data= string
  • Add or remove whitespace between signing and sending
  • Reorder JSON keys between signing and sending
Rule of thumb: Serialize the JSON body to a string once. Use that exact string for both the signing payload and the HTTP request body. Never re-serialize.
# WRONG -- signs compact JSON but requests library re-serializes with spaces
body_dict = {"value": "150000", "currency": "USD"}
body_for_signing = json.dumps(body_dict, separators=(",", ":"))
signing_payload = f"POST\n/v1/payments\n{timestamp}\n{body_for_signing}"
signature = sign(signing_payload)
response = requests.post(url, json=body_dict, ...)  # Re-serializes!

# RIGHT -- serialize once, use everywhere
body_str = json.dumps(body_dict, separators=(",", ":"))
signing_payload = f"POST\n/v1/payments\n{timestamp}\n{body_str}"
signature = sign(signing_payload)
response = requests.post(url, data=body_str, ...)  # Same bytes

2. Encoding Issues

The signing payload must be encoded as UTF-8 bytes before signing. If your language defaults to a different encoding (e.g., Latin-1), the signature will not match.

3. Including the Host in the Path

The REQUEST_PATH component is the path and query string only. Do not include the scheme or host:
CORRECT:   /v1/entities?limit=10
INCORRECT: https://sandbox.api.openfx.com/v1/entities?limit=10

4. Timestamp as String vs Integer

The X-Timestamp header value is a string representation of a Unix timestamp in seconds (not milliseconds). Make sure you are not accidentally sending milliseconds:
CORRECT:   1740500000     (seconds)
INCORRECT: 1740500000000  (milliseconds)

5. Stale Timestamps in Retry Logic

If your HTTP client automatically retries failed requests, make sure the retry logic recomputes the timestamp and re-signs the payload. Retrying with the original timestamp may cause the request to fall outside the tolerance window.

6. Query Parameter Ordering

The query string in the signing payload must match the query string sent in the HTTP request exactly, character for character. If your HTTP library reorders query parameters, you must sign the reordered version.

Building a Signing Helper

In production, you should extract signing into a reusable function or HTTP middleware. Here is a reference implementation:
import time
import json
import base64
from cryptography.hazmat.primitives import serialization


class OpenFXSigner:
    """Reusable request signer for the OpenFX API."""

    def __init__(self, api_key: str, private_key_path: str):
        self.api_key = api_key
        with open(private_key_path, "rb") as f:
            self.private_key = serialization.load_pem_private_key(
                f.read(), password=None
            )

    def sign_request(
        self,
        method: str,
        path: str,
        body: str = "",
    ) -> dict:
        """Build authentication headers for a request.

        Args:
            method: HTTP method (GET, POST, PATCH, DELETE).
            path: Request path with query string (e.g., /v1/entities?limit=10).
            body: Serialized request body, or empty string for no body.

        Returns:
            Dict of headers to merge into the request.
        """
        timestamp = str(int(time.time()))
        signing_payload = f"{method}\n{path}\n{timestamp}\n{body}"
        signature_bytes = self.private_key.sign(
            signing_payload.encode("utf-8")
        )
        signature_b64 = base64.b64encode(signature_bytes).decode("utf-8")

        return {
            "Authorization": f"Bearer {self.api_key}",
            "X-Signature": signature_b64,
            "X-Timestamp": timestamp,
            "Content-Type": "application/json",
        }


# Usage
signer = OpenFXSigner(
    api_key="ofx_sk_sandbox_abc123def456",
    private_key_path="openfx_ed25519.pem",
)

# GET request
headers = signer.sign_request("GET", "/v1/accounts")
response = requests.get("https://sandbox.api.openfx.com/v1/accounts", headers=headers)

# POST request
body = json.dumps({"sellCurrency": "USD", "buyCurrency": "EUR", "sellAmount": "10000.00"}, separators=(",", ":"))
headers = signer.sign_request("POST", "/v1/fx/quotes", body=body)
response = requests.post("https://sandbox.api.openfx.com/v1/fx/quotes", headers=headers, data=body)

Verifying Your Implementation

Use these steps to verify your signing implementation is correct:
  1. Start with sandbox. Sandbox uses the same authentication mechanism as production.
  2. Test a simple GET first. GET /v1/entities with no query parameters is the simplest case.
  3. Check error messages. If you get invalid_signature, the server will include a requestId — contact support with this ID for debugging.
  4. Compare signing payloads. Print the exact signing payload string (with escaped newlines visible) to verify each component is correct.
  5. Verify timestamp freshness. Ensure your timestamp is within the 60-second window.
Once your signing helper works for GET /v1/entities and POST /v1/fx/quotes, it will work for every endpoint in the API. The signing mechanism is uniform across all 138 operations.