BluebulbAPI
    Get started

    Request signing

    Every request is signed with an RSA private key that never leaves your servers. Orbita checks the signature against the public key you uploaded, so a leaked API key on its own can't be used, and a captured request can't be altered or replayed.

    1. Create and upload a key pair

    Generate a 2048-bit (or larger) RSA key pair once:

    openssl genrsa -out private_key.pem 2048
    openssl rsa -in private_key.pem -pubout -out public_key.pem
    • Keep private_key.pem secret. It never leaves your systems and Bluebulb never asks for it.
    • Your Primary Admin pastes the contents of public_key.pem into the Orbita dashboard under Settings → API Keys → Signing key, confirming their password.
    • Bluebulb reviews the key and emails you when it's approved or rejected. Until it's approved, signed requests are rejected with 403.

    2. Build the string to sign

    For every request, join these four values with a newline (\n):

    METHOD
    The HTTP method in upper case: GET or POST.
    pathWithQuery
    The path plus query string exactly as you send it, starting with / — e.g. /api/rates?fromCurrency=usd&toCurrency=ngn. No scheme or host.
    timestampMs
    The current Unix time in milliseconds, as a string. Send the same value in X-Signature-Timestamp.
    sha256hex(body)
    The SHA-256 hash of the exact request body bytes, hex-encoded. For a request with no body, hash an empty string.
    Example string to sign
    POST
    /api/conversions
    1790150590512
    3b4f9c0f8e…(64 hex characters)

    3. Sign it and send the headers

    Sign the string with your private key using RSASSA-PKCS1-v1_5 with SHA-256, base64-encode the result, and send it with the timestamp:

    x-api-key
    Your API key, <appId>.<secret>.
    X-Signature
    The base64 signature.
    X-Signature-Timestamp
    The timestampMs you signed.

    Signing helpers

    Copy one of these into your integration. Every request sample in this reference uses them. Each has been checked against Orbita's own signature verification.

    # Requires bash or zsh, openssl, and perl (macOS) or GNU date (Linux).
    export ORBITA_BASE_URL="{baseUrl}" # https://api.bluebulb.co.uk or https://staging-api.bluebulb.co.uk
    export ORBITA_API_KEY="<appId>.<secret>"
    export ORBITA_PRIVATE_KEY_FILE="private_key.pem"
    export ORBITA_TS_FILE="$HOME/.orbita_last_ts"
    
    # Usage: read TS SIG < <(orbita_sign METHOD PATH_WITH_QUERY BODY)
    orbita_sign() {
      local method="$1" path_q="$2" body="$3"
      # Millisecond timestamp: each signature is accepted once, so every call must
      # use a later timestamp than the last -- even concurrent calls in the same
      # millisecond. The last value is kept in a file (orbita_sign runs in a
      # subshell), guarded by an mkdir lock, which is atomic and works without flock.
      local ts last lock="$ORBITA_TS_FILE.lock"
      until mkdir "$lock" 2>/dev/null; do sleep 0.01; done
      ts=$(date +%s%3N 2>/dev/null)
      case "$ts" in
        *N*|"") ts=$(perl -MTime::HiRes=time -e 'printf("%d", time() * 1000)') ;;
      esac
      last=$(( $(cat "$ORBITA_TS_FILE" 2>/dev/null || echo 0) + 0 ))
      if [ "$ts" -le "$last" ]; then ts=$((last + 1)); fi
      echo "$ts" > "$ORBITA_TS_FILE"
      rmdir "$lock"
      local body_hash
      body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -r | cut -d' ' -f1)
      local sig
      sig=$(printf '%s\n%s\n%s\n%s' "$method" "$path_q" "$ts" "$body_hash" \
        | openssl dgst -sha256 -sign "$ORBITA_PRIVATE_KEY_FILE" | base64 | tr -d '\n')
      echo "$ts $sig"
    }
    Sign the exact bytes you send. Serialize the body once, sign that string, and send that same string. Don't pass an object to your HTTP client and serialize it separately — key order or whitespace can differ and the signature won't match.

    Rules Orbita enforces

    • X-Signature-Timestamp must be within 5 minutes of Orbita's clock, in either direction. Keep your servers synced with NTP.
    • Each signature is accepted once. Resending the same headers returns 403 Request signature already used. Because an identical request signed at the same millisecond produces the same signature, give every attempt — including concurrent retries — its own distinct X-Signature-Timestamp (e.g. never reuse the previous attempt's value; bump it by 1 ms if the clock hasn't moved), then sign again.
    • The key must be RSA, at least 2048 bits, PEM-encoded.

    Signing multipart requests

    Payout requests with a supporting document are sent as multipart/form-data. Sign the exact multipart body bytes, boundary included. The simplest way is to encode the form yourself, then sign and send those bytes:

    // Offshore payout with a supporting document (Node 18+).
    const form = new FormData();
    form.append("customerReference", "INV-90872");
    form.append("amount", "5000");
    form.append("sourceCurrency", "usd");
    form.append("destinationCurrency", "usd");
    form.append("tradeType", "FX_OFFSHORE");
    form.append("beneficiaryId", "b7d3e1f2-9a4c-4b6d-8e2f-1c3a5b7d9e0f");
    form.append("file", new Blob([fs.readFileSync("invoice.pdf")], { type: "application/pdf" }), "invoice.pdf");
    
    // Serialize the form once to get its exact bytes and boundary, then sign those bytes.
    const encoded = new Request("http://localhost", { method: "POST", body: form });
    const bodyBytes = Buffer.from(await encoded.arrayBuffer());
    const contentType = encoded.headers.get("content-type"); // includes the boundary
    
    const timestampMs = nextTimestampMs();
    const bodyHash = crypto.createHash("sha256").update(bodyBytes).digest("hex");
    const payload = ["POST", "/api/payouts", timestampMs, bodyHash].join("\n");
    const signature = crypto.createSign("RSA-SHA256").update(payload).sign(PRIVATE_KEY, "base64");
    
    const res = await fetch(BASE_URL + "/api/payouts", {
      method: "POST",
      headers: {
        "x-api-key": API_KEY,
        "X-Signature": signature,
        "X-Signature-Timestamp": timestampMs,
        "Content-Type": contentType,
      },
      body: bodyBytes,
    });
    Continues from the Node helper above (crypto, fs, BASE_URL, API_KEY, PRIVATE_KEY, nextTimestampMs).

    Troubleshooting

    Invalid request signature
    The string you signed differs from the request Orbita received: a query string signed but not sent (or the reverse), different query parameter order or encoding, a trailing slash, or a body re-serialized after signing.
    Missing or expired request signature
    A header is missing, the timestamp is in seconds instead of milliseconds, or your server clock is off by more than 5 minutes.
    Request signature already used
    A retry reused the original headers. Sign every attempt separately.
    No approved signing key on file…
    Your public key hasn't been uploaded, is awaiting approval, or was rejected. Check the dashboard.

    Responses to signed requests are signed by Orbita too — see the next page to verify them.